☰

Video Helper

安全防護(擋彈窗、跳轉、危險下載)、省流模式(廣告不下載)、影片狀態、隱藏廣告、自動略過廣告、載入時間評估、一鍵下載

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

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

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

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

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

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

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

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

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

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         Video Helper
// @namespace    https://github.com/dadanny1122/video-helper
// @version      3.12.0
// @description  安全防護(擋彈窗、跳轉、危險下載)、省流模式(廣告不下載)、影片狀態、隱藏廣告、自動略過廣告、載入時間評估、一鍵下載
// @author       dadanny1122
// @license      MIT
// @homepageURL  https://github.com/dadanny1122/video-helper
// @supportURL   https://github.com/dadanny1122/video-helper/issues
// @match        *://*.pornhub.com/*
// @match        *://*.xvideos.com/*
// @match        *://*.xnxx.com/*
// @match        *://*.youporn.com/*
// @match        *://*.redtube.com/*
// @match        *://*.jable.tv/*
// @match        *://*.hanime1.me/*
// @match        *://*.eporner.com/*
// @match        *://*.hqporner.com/*
// @match        *://*.beeg.com/*
// @match        *://*.sxyprn.com/*
// @match        *://*.spankbang.com/*
// @match        *://*.xmoviesforyou.com/*
// @match        *://*.porntrex.com/*
// @match        *://*.youjizz.com/*
// @match        *://*.pornone.com/*
// @match        *://*.3movs.com/*
// @match        *://*.tube8.com/*
// @match        *://*.porndig.com/*
// @match        *://*.cumlouder.com/*
// @match        *://*.txxx.com/*
// @match        *://*.porndoe.com/*
// @match        *://*.vporn.com/*
// @include      /^https?:\/\/([^\/]*\.)?xhamster\d*\.(com|desi|one)\//
// @include      /^https?:\/\/([^\/]*\.)?missav[^\/]*\//
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/mux.min.js
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_info
// @grant        GM_setClipboard
// @grant        unsafeWindow
// @connect      *
// @connect      127.0.0.1
// @run-at       document-start
// @inject-into  page
// ==/UserScript==

/*
 * 架構說明
 *   第一層 核心:共用工具、設定、提示介面
 *   第二層 網站轉接器(SITES):每個網站開哪些功能、影片網址在哪
 *   第三層 功能模組(MODULES):安全防護、隱藏廣告、跳廣告、評估、列表標示、下載
 *
 * 加新網站 → 在 SITES 加一筆
 * 加新功能 → 在 MODULES 加一個,再到網站的 features 裡打開
 */

(function () {
  'use strict';

  /* ==================================================================
   * 第一層:核心
   * ================================================================== */

  const PW = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
  const IS_TOP = window.top === window.self;
  const HOST = location.hostname;
  const baseOf = h => h.split('.').slice(-2).join('.');
  const HOME = baseOf(HOST);

  // ---- 設定(存在暴力猴裡,所有網站共用)----
  // 主要存在暴力猴裡(所有網站共用),同時在網站的 localStorage 留一份備份
  const store = {
    get(k, d) {
      try { const v = GM_getValue(k); if (v !== undefined) return v; } catch (e) {}
      try { const b = localStorage.getItem('vh_' + k); if (b !== null) return JSON.parse(b); } catch (e) {}
      return d;
    },
    set(k, v) {
      try { GM_setValue(k, v); } catch (e) {}
      try { localStorage.setItem('vh_' + k, JSON.stringify(v)); } catch (e) {}
    },
  };
  const settings = store.get('settings', {});
  const isOn = name => settings[name] !== false;   // 預設全部開啟

  // ---- 網址判斷 ----
  const DANGER_EXT = /\.(exe|msi|apk|bat|cmd|scr|vbs|ps1|jar|dmg|pkg|com)$/i;

  function isExternal(url) {
    try {
      const u = new URL(url, location.href);
      return /^https?:$/.test(u.protocol) && baseOf(u.hostname) !== HOME;
    } catch (e) { return false; }
  }

  // 偽裝網址:自己網站的 /pop?url=https://廣告商... → 取出裡面真正的目的地
  function unwrap(url) {
    try {
      const u = new URL(url, location.href);
      for (const v of u.searchParams.values()) if (/^https?:\/\//i.test(v)) return v;
    } catch (e) {}
    return url;
  }

  // 這個網址開出去,是不是廣告(直接開到別的網站、包著別的網站、或是網站自己的廣告路徑)
  function isAdTarget(url) {
    return isExternal(url) || isExternal(unwrap(url)) || isAd(url);
  }

  function isDangerous(url) {
    try { return DANGER_EXT.test(new URL(url, location.href).pathname); }
    catch (e) { return false; }
  }

  // ---- 已知的廣告網路(成人網站常用的 + 一般大型廣告商)----
  const AD_DOMAINS = [
    'trafficjunky.net', 'trafficjunky.com', 'exoclick.com', 'exosrv.com', 'exdynsrv.com', 'pemsrv.com',
    'juicyads.com', 'tsyndicate.com', 'trafficstars.com', 'realsrv.com', 'magsrv.com',
    'popads.net', 'popcash.net', 'propellerads.com', 'adsterra.com', 'clickadu.com',
    'hilltopads.net', 'ero-advertising.com', 'a-ads.com', 'doubleclick.net',
    'googlesyndication.com', 'adnxs.com',
  ];
  const AD_RE = new RegExp('(^|\\.)(' + AD_DOMAINS.map(d => d.replace(/\./g, '\\.')).join('|') + ')$');

  // 使用者自己封鎖的網域(個人黑名單)
  const userBlock = new Set(store.get('userBlock', []));

  // 有些網站的廣告是從「自己的網址」拿的(例如 Pornhub 的 /_xa/ads),由網站轉接器設定
  let siteAdPath = null;

  function isAd(url) {
    try {
      const u = new URL(url, location.href);
      if (siteAdPath && baseOf(u.hostname) === HOME && siteAdPath.test(u.pathname)) return true;
      // ExoClick 的廣告程式不管放在哪個網域,檔名都叫 ad-provider.js(XVideos 影片前廣告就是它)
      if (/\/ad-provider\.js$/.test(u.pathname)) return true;
      return AD_RE.test(u.hostname) || userBlock.has(baseOf(u.hostname));
    } catch (e) { return false; }
  }

  // ---- 格式化 ----
  function fmt(sec) {
    if (!isFinite(sec) || sec < 0) return '計算中';
    if (sec < 60) return sec.toFixed(1) + ' 秒';
    return Math.floor(sec / 60) + ' 分 ' + Math.round(sec % 60) + ' 秒';
  }

  function fmtSize(bytes) {
    if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + ' GB';
    return (bytes / 1e6).toFixed(0) + ' MB';
  }

  function parseDur(str) {
    if (!str) return 0;
    if (/^\d+$/.test(str)) return Number(str);
    const m = str.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
    if (!m) return 0;
    return (+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0);
  }

  // ---- 網速(量到就存起來,所有網站共用)----
  const BITRATE = { 240: 0.5, 360: 0.8, 480: 1.5, 720: 3, 1080: 5.5, 1440: 9, 2160: 16 };

  function bitrateFor(h) {
    if (!h) return BITRATE[720];
    let best = 240;
    for (const k of Object.keys(BITRATE).map(Number)) if (h >= k) best = k;
    return BITRATE[best];
  }

  function measureSpeed() {
    const entries = performance.getEntriesByType('resource')
      .filter(e => e.transferSize > 100000 && e.responseEnd - e.responseStart > 0)
      .slice(-10);
    if (entries.length) {
      let bytes = 0, ms = 0;
      for (const e of entries) { bytes += e.transferSize; ms += e.responseEnd - e.responseStart; }
      const mbps = bytes * 8 / (ms / 1000) / 1e6;
      store.set('speed', mbps);
      return { mbps, source: '實測' };
    }
    const saved = store.get('speed', 0);
    if (saved) return { mbps: saved, source: '上次實測' };
    if (navigator.connection && navigator.connection.downlink) {
      return { mbps: navigator.connection.downlink, source: '粗估' };
    }
    return null;
  }

  function ratingOf(ratio) { return ratio >= 2 ? '🟢' : ratio >= 1.2 ? '🟡' : '🔴'; }

  // ---- 載入品質紀錄:每個網站最近 20 次「實際開始播放秒數 ÷ 預估秒數」----
  function startupSamples(site) {
    const all = store.get('vqSamples', {}) || {};
    return Array.isArray(all[site]) ? all[site] : [];
  }

  function recordStartup(ratio) {
    if (!isFinite(ratio) || ratio < 0.05 || ratio > 20) return;     // 太離譜的(例如網頁卡住)不記
    const all = store.get('vqSamples', {}) || {};
    const key = HOME;
    all[key] = [ratio, ...startupSamples(key)].slice(0, 20);
    store.set('vqSamples', all);
  }

  function startupFactor() {
    const xs = startupSamples(HOME).slice().sort((a, b) => a - b);
    if (xs.length < 3) return [null, xs.length];
    const m = xs.length % 2 ? xs[(xs.length - 1) / 2] : (xs[xs.length / 2 - 1] + xs[xs.length / 2]) / 2;
    return [Math.min(5, Math.max(0.2, m)), xs.length];
  }

  // 「開始播放要多久」的原始預估(還沒校正):先載 5 秒影片的時間 + 0.5 秒
  function startupEstimate(height) {
    const sp = measureSpeed();
    return sp ? bitrateFor(height) * 5 / sp.mbps + 0.5 : null;
  }

  function statusLine(vq) {
    if (!vq) return '';
    const stall = vq.stalls ? `|卡頓 ${vq.stalls} 次(共 ${(vq.stallMs / 1000).toFixed(1)} 秒)` : '';
    const text = {
      searching: '🔎 尋找影片中',
      loading: shared.adShowing ? '📺 廣告播放中' : '⏳ 影片載入中…',
      ready: '✅ 已經可以播放了',
      playing: '▶ 播放中' + stall,
      buffering: '⏳ 緩衝中…' + stall,
      paused: '⏸ 暫停' + stall,
      ended: '⏹ 播完了' + stall,
      error: '❌ 載入失敗:' + vq.error,
    }[vq.state] || '';
    return text ? `<b>${text}</b><br>` : '';
  }

  function recommend(mbps) {
    const keys = Object.keys(BITRATE).map(Number).reverse();
    for (const k of keys) if (mbps / BITRATE[k] >= 1.2) return k + 'p';
    return '240p';
  }

  // ---- 網路請求 ----
  function gmGet(url, type) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET', url, responseType: type, timeout: 30000,
        onload: r => (r.status >= 200 && r.status < 300) ? resolve(r.response) : reject(new Error('HTTP ' + r.status)),
        onerror: () => reject(new Error('連線失敗')),
        ontimeout: () => reject(new Error('逾時')),
      });
    });
  }

  async function getData(url, type) {
    try {
      const r = await fetch(url);
      if (!r.ok) throw new Error('HTTP ' + r.status);
      return type === 'text' ? await r.text() : await r.arrayBuffer();
    } catch (e) {
      return gmGet(url, type);
    }
  }

  function getSize(url) {
    return new Promise(resolve => {
      GM_xmlhttpRequest({
        method: 'HEAD', url, timeout: 10000,
        onload: r => {
          const m = (r.responseHeaders || '').match(/content-length:\s*(\d+)/i);
          resolve(m ? +m[1] : 0);
        },
        onerror: () => resolve(0),
        ontimeout: () => resolve(0),
      });
    });
  }

  // ---- 這個分頁暫停擋廣告(存在 sessionStorage:只對這個分頁有效,關掉分頁就恢復)----
  const PAUSABLE = ['security', 'siteRules', 'saveData', 'hideAds', 'autoSkip'];
  const PAUSED = (() => { try { return sessionStorage.getItem('vh_paused') === '1'; } catch (e) { return false; } })();
  function setPaused(on) {
    try { if (on) sessionStorage.setItem('vh_paused', '1'); else sessionStorage.removeItem('vh_paused'); } catch (e) {}
    location.reload();
  }

  // ---- 攔截紀錄:每次擋了什麼、為什麼擋(最近 50 筆,只在這個分頁,不存檔)----
  const WHY = [
    ['擋下彈出視窗', '網頁想偷開新分頁或彈出視窗,開到別的網站(常趁你按播放時一起開)'],
    ['擋下危險檔案下載', '安裝檔(.exe、.apk 等)常是病毒;看影片不需要安裝任何程式'],
    ['擋下廣告連結', '連結會開新分頁到別的網站,是常見的廣告手法'],
    ['擋下隱藏的彈窗連結', '網頁做了看不見的連結再自己點它,是彈窗廣告的手法'],
    ['擋下自動跳轉', '網頁想把你帶去別的網站,但不是你點的'],
    ['擋下通知權限請求', '「允許通知」常被拿來推送垃圾訊息和假的中毒警告'],
    ['擋下透明廣告點擊層', '一層蓋住整個畫面的透明東西,點哪裡都會開廣告'],
  ];
  const whyOf = msg => (WHY.find(([k]) => msg.startsWith(k)) || [null, ''])[1];
  const hostOf = url => { try { return new URL(url, location.href).hostname; } catch (e) { return ''; } };
  const events = [];
  function logEvent(what, why, url) {
    const host = url ? hostOf(url) : '';
    const last = events[0];
    if (last && last.what === what && last.host === host) {          // 同一件事連續發生 → 合併成一筆
      last.n++; last.time = new Date().toLocaleTimeString();
      return;
    }
    events.unshift({ what, why, host, url: url ? String(url).split('?')[0].slice(0, 200) : '', n: 1,
                     time: new Date().toLocaleTimeString() });
    events.length = Math.min(events.length, 50);
  }

  // 「這是誤擋」→ 複製一份回報(不含網址後面的參數)
  function reportWrong(ev) {
    const text = JSON.stringify({
      type: '誤擋回報', version: (typeof GM_info !== 'undefined' && GM_info.script) ? GM_info.script.version : '?',
      site: (typeof site !== 'undefined' && site) ? site.name : HOST, page: location.href.split('?')[0],
      event: ev, recent: events.slice(0, 10),
    }, null, 1);
    navigator.clipboard.writeText(text).then(
      () => alert('✅ 誤擋回報已複製,貼給開發者就好。\n\n要先看影片的話,可以按「⏸ 這個分頁先暫停擋廣告」。'),
      () => prompt('請複製下面的內容:', text));
  }

  // ---- 左下角盾牌 + 提示 ----
  const ui = {
    count: 0, saved: 0, blockedHosts: {}, actions: {}, shield: null, toasts: null, pending: [], realOpen: null,

    report(msg, url) {
      logEvent(msg, whyOf(msg), url);
      this.count++;
      this.pending.push([msg, url]);
      this.flush();
    },

    flush() {
      if (!document.body || !IS_TOP) return;
      if (!this.shield) {
        this.shield = document.createElement('div');
        this.shield.className = 'vh-ui';
        this.shield.style.cssText = 'position:fixed;bottom:20px;left:20px;z-index:2147483647;background:rgba(0,120,60,.9);' +
          'color:#fff;padding:6px 12px;border-radius:16px;font:bold 13px sans-serif;cursor:pointer;';
        this.shield.title = '點一下:查看這頁連到的其他網站';
        this.shield.addEventListener('click', () => this.openPanel());
        this.toasts = document.createElement('div');
        this.toasts.className = 'vh-ui';
        this.toasts.style.cssText = 'position:fixed;bottom:60px;left:20px;z-index:2147483647;display:flex;' +
          'flex-direction:column-reverse;gap:6px;max-width:360px;';
        document.body.append(this.shield, this.toasts);
      }
      this.shield.textContent = PAUSED ? '⏸ 這個分頁暫停擋廣告中(點這裡恢復)'
        : `🛡 已攔截 ${this.count} 次` + (this.saved ? `|省下 ${this.saved} 個廣告請求` : '');
      if (PAUSED) this.shield.style.background = 'rgba(180,120,0,.95)';
      while (this.pending.length) this.toast(...this.pending.shift());
    },

    // ---- 收集器:列出這頁連到的其他網站 ----
    openPanel() {
      if (this.panel) {
        const wasList = this.panel.dataset.kind === 'list';
        this.panel.remove(); this.panel = null;
        if (wasList) return;   // 再點一次盾牌 = 關閉清單
      }

      // 統計每個網域連了幾次(實際載入的 + 被擋的)
      const stat = {};
      for (const e of performance.getEntriesByType('resource')) {
        let h; try { h = new URL(e.name).hostname; } catch (err) { continue; }
        const b = baseOf(h);
        if (b === HOME) continue;
        stat[b] = stat[b] || { n: 0, blocked: 0, how: {}, hosts: {} };
        stat[b].n++;
        stat[b].hosts[h] = 1;                              // 完整網址(例如 s.pemsrv.com),拿來比對廣告清單
        const how = { img: '圖片', script: '程式', xmlhttprequest: '資料', fetch: '資料', beacon: '追蹤',
          iframe: '框框', video: '影片', audio: '聲音', css: 'CSS', link: '預先載入', other: '其他' }[e.initiatorType] || e.initiatorType;
        stat[b].how[how] = 1;
      }
      for (const [b, n] of Object.entries(this.blockedHosts)) {
        stat[b] = stat[b] || { n: 0, blocked: 0, how: {}, hosts: {} };
        stat[b].blocked += n;
      }
      const rows = Object.entries(stat).sort((a, b) => (b[1].n + b[1].blocked) - (a[1].n + a[1].blocked));

      const p = this.panel = document.createElement('div');
      p.className = 'vh-ui';
      p.dataset.kind = 'list';
      p.style.cssText = 'position:fixed;bottom:60px;left:20px;z-index:2147483647;background:rgba(20,20,20,.97);color:#fff;' +
        'padding:12px 14px;border-radius:10px;font:13px/1.6 sans-serif;width:380px;max-height:70vh;overflow-y:auto;' +
        'box-shadow:0 2px 12px rgba(0,0,0,.5);';
      if (PAUSED) {
        const pb = document.createElement('div');
        pb.style.cssText = 'margin-bottom:10px;padding:8px;background:#5d4300;border-radius:6px;';
        pb.innerHTML = '⏸ <b>這個分頁暫停擋廣告中</b><br><small>關掉分頁就會自動恢復</small> ';
        const go = document.createElement('span');
        go.textContent = '▶ 現在恢復';
        go.style.cssText = 'cursor:pointer;background:#2e7d32;padding:2px 10px;border-radius:4px;';
        go.addEventListener('click', () => setPaused(false));
        pb.appendChild(go);
        p.appendChild(pb);
      }

      // 最近攔截了什麼、為什麼
      const evBox = document.createElement('div');
      evBox.innerHTML = '<b>最近攔截</b>' + (events.length ? '' : '<div style="opacity:.7;font-size:12px">這個分頁還沒有攔截任何東西</div>');
      for (const ev of events.slice(0, 12)) {
        const row = document.createElement('div');
        row.style.cssText = 'margin-top:4px;padding:4px 8px;background:#2a2a2a;border-radius:6px;font-size:12px;';
        const head = document.createElement('div');
        head.textContent = `${ev.time} ${ev.what}${ev.n > 1 ? ` ×${ev.n}` : ''}${ev.host ? `(${ev.host})` : ''}`;
        const why = document.createElement('div');
        why.style.cssText = 'opacity:.7;';
        why.textContent = ev.why ? '原因:' + ev.why : '';
        const wrong = document.createElement('span');
        wrong.textContent = '這是誤擋?';
        wrong.style.cssText = 'float:right;color:#8cf;text-decoration:underline;cursor:pointer;';
        wrong.addEventListener('click', () => reportWrong(ev));
        head.prepend(wrong);
        row.append(head, why);
        evBox.appendChild(row);
      }
      p.appendChild(evBox);

      const hostsTitle = document.createElement('div');
      hostsTitle.innerHTML = '<br><b>這頁連到的其他網站</b><div style="opacity:.7;font-size:12px">' +
        '影片和圖片常放在網站自己的另一個網域(例如 phncdn.com),封鎖前先確認不是它</div>';
      p.appendChild(hostsTitle);

      if (!rows.length) p.insertAdjacentHTML('beforeend', '<div style="margin-top:8px">目前沒有</div>');

      // 🤖 問 AI:判斷還沒被內建規則擋的網域
      const aiTags = {};                                   // 網域 → 顯示 AI 結果的那一格
      const OWN = new Set((site && site.ownDomains) || []);
      const unknown = rows.map(([b]) => b).filter(b => !AD_RE.test(b) && !OWN.has(b)).slice(0, 30);
      if (unknown.length) {
        const aiBar = document.createElement('div');
        aiBar.style.cssText = 'margin-top:6px;font-size:12px;';
        const aiBtn = document.createElement('span');
        aiBtn.textContent = `🔍 檢查這 ${unknown.length} 個是不是廣告`;
        aiBtn.title = '用電腦版裡的公開廣告清單比對(免費);有設定 AI 金鑰才會再問 AI。只給建議,封不封鎖你決定';
        aiBtn.style.cssText = 'cursor:pointer;background:#6a1b9a;padding:3px 10px;border-radius:4px;';
        const aiMsg = document.createElement('span');
        aiMsg.style.cssText = 'margin-left:8px;opacity:.8;';
        aiBtn.addEventListener('click', async () => {
          if (aiBtn.dataset.busy) return;
          aiBtn.dataset.busy = '1';
          aiMsg.textContent = '檢查中…(第一次要下載清單,約幾秒)';
          try {
            const hostMap = {};
            for (const d of unknown) hostMap[d] = Object.keys(stat[d].hosts || {}).slice(0, 20);
            const res = await askAi(unknown, hostMap);
            let n = 0;
            for (const [d, r] of Object.entries(res)) {
              const tag = aiTags[d];
              if (!tag) continue;
              tag.textContent = (r.block ? '⚠ ' : r.label === '不確定' ? '? ' : '✓ ') + r.label + (r.reason ? ':' + r.reason : '');
              tag.style.color = r.block ? '#ff8a80' : r.label === '不確定' ? '#bbb' : '#a5d6a7';
              if (r.block) n++;
            }
            aiMsg.textContent = n ? `建議封鎖 ${n} 個(紅字),確認後按「封鎖」` : '沒有找到已知的廣告';
          } catch (err) {
            aiMsg.textContent = '❌ ' + err.message;
          }
          delete aiBtn.dataset.busy;
        });
        aiBar.append(aiBtn, aiMsg);
        p.appendChild(aiBar);
      }

      for (const [b, st] of rows) {
        const builtIn = AD_RE.test(b);
        const mine = userBlock.has(b);
        const row = document.createElement('div');
        row.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:6px;padding:4px 8px;background:#333;border-radius:6px;';
        const name = document.createElement('div');
        name.style.cssText = 'flex:1;min-width:0;';
        const dom = document.createElement('div');
        dom.style.cssText = 'font-weight:bold;word-break:break-all;';
        dom.textContent = b;
        const tag = aiTags[b] = document.createElement('div');   // 「🔍 檢查」的結果會填在這裡
        tag.style.cssText = 'font-size:11px;';
        if (OWN.has(b)) {
          tag.textContent = '✓ 網站自己的影片/圖片伺服器(封鎖了影片會播不了)';
          tag.style.color = '#a5d6a7';
        }
        const info = document.createElement('div');
        info.style.cssText = 'font-size:11px;opacity:.6;';
        name.append(dom, tag, info);
        const hows = Object.keys(st.how).join('、');
        info.textContent = (st.blocked ? `擋 ${st.blocked}` : '') + (st.blocked && st.n ? '|' : '') +
          (st.n ? `載入 ${st.n}${hows ? '(' + hows + ')' : ''}` : '');
        const btn = document.createElement('span');
        btn.style.cssText = 'cursor:pointer;padding:2px 8px;border-radius:4px;font-size:12px;white-space:nowrap;';
        if (builtIn) {
          btn.textContent = '內建已擋';
          btn.style.background = '#2e7d32';
          btn.style.cursor = 'default';
        } else {
          btn.textContent = mine ? '解除封鎖' : '封鎖';
          btn.style.background = mine ? '#555' : OWN.has(b) ? '#555' : '#c62828';
          btn.addEventListener('click', () => {
            if (!userBlock.has(b) && OWN.has(b) &&
                !confirm(`${b} 是這個網站自己的影片/圖片伺服器,封鎖後影片可能播不了。\n確定要封鎖嗎?`)) return;
            if (userBlock.has(b)) userBlock.delete(b); else userBlock.add(b);
            store.set('userBlock', [...userBlock]);
            btn.textContent = userBlock.has(b) ? '解除封鎖' : '封鎖';
            btn.style.background = userBlock.has(b) ? '#555' : '#c62828';
          });
        }
        row.append(name, btn);
        p.appendChild(row);
      }

      const foot = document.createElement('div');
      foot.style.cssText = 'margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;';
      const copy = document.createElement('span');
      copy.textContent = '📋 複製我的黑名單';
      copy.style.cssText = 'cursor:pointer;background:#1565c0;padding:3px 10px;border-radius:4px;';
      copy.addEventListener('click', () => {
        const text = [...userBlock].join('\n') || '(黑名單是空的)';
        navigator.clipboard.writeText(text).then(() => { copy.textContent = '✅ 已複製'; }, () => { prompt('複製下面的內容:', text); });
      });
      const note = document.createElement('div');
      note.style.cssText = 'font-size:12px;opacity:.7;width:100%;';
      note.textContent = `我的黑名單:${userBlock.size} 個網域|封鎖後重新整理網頁生效`;
      const mkBtn = (text, color, fn) => {
        const b = document.createElement('span');
        b.textContent = text;
        b.style.cssText = `cursor:pointer;background:${color};padding:3px 10px;border-radius:4px;`;
        b.addEventListener('click', fn);
        return b;
      };
      const settingsBtn = mkBtn('⚙ 功能開關', '#555', () => this.actions.settings && this.actions.settings());
      const diagBtn = mkBtn('🔧 診斷', '#6a1b9a', () => this.actions.diagnose && this.actions.diagnose());
      const healthBtn = mkBtn('🩺 狀態檢查', '#00695c', () => this.actions.health && this.actions.health());
      const pauseBtn = PAUSED
        ? mkBtn('▶ 恢復擋廣告', '#2e7d32', () => setPaused(false))
        : mkBtn('⏸ 這個分頁先暫停擋廣告', '#8d6e00', () => {
          if (confirm('這個分頁先暫停擋廣告、擋彈窗嗎?\n\n網頁會重新整理。關掉這個分頁就會自動恢復,其他分頁不受影響。')) setPaused(true);
        });
      foot.append(copy, settingsBtn, healthBtn, diagBtn, pauseBtn, note);
      p.appendChild(foot);

      document.body.appendChild(p);
    },

    toast(msg, url) {
      const t = document.createElement('div');
      t.style.cssText = 'background:rgba(20,20,20,.95);color:#fff;padding:8px 12px;border-radius:8px;' +
        'font:13px/1.5 sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.4);word-break:break-all;';
      const line = document.createElement('div');
      line.textContent = '🛡 ' + msg;
      t.appendChild(line);
      if (url) {
        const host = document.createElement('div');
        host.style.cssText = 'opacity:.7;font-size:12px;';
        try { host.textContent = new URL(url, location.href).hostname; } catch (e) {}
        const go = document.createElement('span');
        go.textContent = '我確定要開啟';
        go.style.cssText = 'color:#8cf;text-decoration:underline;cursor:pointer;font-size:12px;';
        go.addEventListener('click', () => { t.remove(); if (ui.realOpen) ui.realOpen.call(PW, url, '_blank', 'noopener'); });
        t.append(host, go);
      }
      this.toasts.appendChild(t);
      setTimeout(() => t.remove(), 7000);
    },
  };

  /* ==================================================================
   * 第二層:網站轉接器
   * ================================================================== */

  const ALL = ['security', 'saveData', 'autoSkip', 'videoStatus', 'estimator', 'listLabels', 'download'];

  // 只擋廣告、不下載的網站共用的功能清單
  const ADBLOCK_ONLY = ['security', 'saveData', 'hideAds', 'autoSkip', 'videoStatus', 'estimator', 'listLabels'];
  const TUBE_LIMITS = ['不提供下載', '剛加入,還沒有這個網站專屬的擋廣告規則;有漏掉的廣告請回報'];

  const SITES = [
    {
      name: 'Pornhub',
      match: /(^|\.)pornhub\.com$/,
      features: ALL,
      // 影片前廣告清單、側邊廣告都是跟 Pornhub 自己的 /_xa/ads… 要的
      adPath: /^\/_xa\/ads/,
      // 網站自己的影片、圖片伺服器:封鎖了網站就壞掉
      ownDomains: ['phncdn.com'],
      // 以下參考 uBlock Origin 的規則庫(uBlockOrigin/uAssets),用自己的程式碼重新實作
      rules: {
        // uBlock:pornhub.*##.watchpageAd 等 → 藏起廣告區塊
        hide: ['.watchpageAd', '.middleAdContainer', '.mgp_overlayContainer', '#pb_block', '.js-adsRemoveButton',
          '.ad-tabSplit', '#js-abContainterMain', '.emptyBlockSpace', '#relatedVideosCenter > .wrapVideoBlock',
          'a[href^="http://ads.trafficjunky.net/"]', '.livesex', '.realsex', '.joinNowCPPBtn', '.js_joinNowCPPBtn',
          'div[id^="customSkin"]'],
        // uBlock:+js(set, abp1, 1) → 設定一個值,讓網站改走比較少廣告的版本
        setGlobals: { abp1: 1 },
        // uBlock:+js(acs, Object.defineProperty, trafficjunky)、+js(rmnt, script, /interstitial/)
        //   → 網頁裡的程式,同一組的字「全部都有」才不讓它執行(條件嚴格,避免誤傷正常程式)
        blockInlineScript: [[/trafficjunky/, /Object\.defineProperty/], [/\/interstitial\?/]],
        // uBlock:+js(nostif, adsbytrafficjunkycontext) → 取消「幾秒後載入廣告」的計時器
        blockTimeout: [/adsbytrafficjunkycontext/],
        // 影片前廣告的設定改名,播放器就找不到(概念同 uBlock 的 json-prune)
        renameKeys: ['adRollGlobalConfig'],
      },
      isVideoPage: () => location.pathname.includes('view_video'),
      findSources: findPhSources,
      limits: ['寫在網頁最開頭的廣告程式仍會被瀏覽器先下載(腳本只能讓它不執行)',
               'Pornhub 改版時,網站專屬規則可能失效(盾牌數字突然變很少就是徵兆)'],
    },
    {
      name: 'XVideos',
      match: /(^|\.)xvideos\.com$/,
      features: ALL,
      // uBlock:||xvideos.com/lvcsm/abck-banners/
      adPath: /^\/lvcsm\/abck-banners\//,
      ownDomains: ['xvideos-cdn.com'],
      rules: {
        // uBlock:xvideos.com##a[href*="/go.php?"][href*="&ad="]
        hide: ['a[href*="/go.php?"][href*="&ad="]'],
        // uBlock:+js(aeld, click, ShouldShow) → 不讓「點哪裡都開廣告」的監聽器裝上去
        blockListeners: [{ type: 'click', match: /ShouldShow/ }],
      },
      isVideoPage: () => /setVideo(UrlHigh|UrlLow|HLS)/.test(document.documentElement.innerHTML),
      findSources: findXvSources,
      limits: ['720p 以上是切片,在瀏覽器裡下載完要轉 MP4,大檔建議用「🖥 送到電腦版」'],
    },
    {
      name: 'XNXX',
      match: /(^|\.)xnxx\.com$/,
      features: ALL,
      // 跟 XVideos 同一家公司,播放器寫法一樣
      ownDomains: ['xnxx-cdn.com', 'xvideos-cdn.com'],
      rules: { blockListeners: [{ type: 'click', match: /ShouldShow/ }] },
      isVideoPage: () => /setVideo(UrlHigh|UrlLow|HLS)/.test(document.documentElement.innerHTML),
      findSources: findXvSources,
      limits: ['剛加入;跟 XVideos 用同一套方法,有問題請回報'],
    },
    {
      name: 'xHamster',
      match: /(^|\.)xhamster\d*\.(com|desi|one)$/,
      features: ALL,
      ownDomains: ['xhcdn.com'],
      isVideoPage: () => /\/videos\//.test(location.pathname) || !!(PW.initials && PW.initials.xplayerSettings),
      findSources: findXhSources,
      limits: ['加密的網址會自動解開;網站換新加密法時改成「交給電腦版」下載'],
    },
    {
      name: 'YouPorn',
      match: /(^|\.)youporn\.com$/,
      features: ALL,
      ownDomains: ['ypncdn.com'],
      isVideoPage: () => /mediaDefinition/.test(document.documentElement.innerHTML),
      findSources: findAyloSources,
      limits: ['剛加入;跟 Pornhub 同一家公司,有問題請回報'],
    },
    {
      name: 'RedTube',
      match: /(^|\.)redtube\.com$/,
      features: ALL,
      ownDomains: ['rdtcdn.com'],
      isVideoPage: () => /mediaDefinition/.test(document.documentElement.innerHTML),
      findSources: findAyloSources,
      limits: ['剛加入;跟 Pornhub 同一家公司,有問題請回報'],
    },
    // 盜版轉貼站:只開防護和隱藏廣告
    // 不開下載:內容多為片商作品的轉載
    { name: 'Jable', match: /(^|\.)jable\.tv$/,
      features: ['security', 'saveData', 'hideAds', 'autoSkip', 'videoStatus', 'estimator', 'listLabels'],
      limits: ['不提供下載(內容多為片商作品的轉載)'] },
    // MissAV:按播放時會開 /pop?url=廣告商 的新分頁
    // 不開下載:內容多為片商作品的轉載
    { name: 'MissAV', match: /(^|\.)missav[^.]*\.[a-z]+$/, adPath: /^\/pop/,
      features: ['security', 'saveData', 'hideAds', 'autoSkip', 'videoStatus', 'estimator', 'listLabels'],
      limits: ['不提供下載(內容多為片商作品的轉載)', 'MissAV 常換網址;換到不是 missav 開頭的網址時腳本不會啟動'] },
    // 其他影片網站(參考 ThePornDude 的影片網站排行):只開擋廣告、預估時間,不開下載
    //   這些網站很多影片是片商作品的轉載;xHamster 之後測試穩定再考慮加下載
    { name: 'Eporner', match: /(^|\.)eporner\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: 'HQporner', match: /(^|\.)hqporner\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: 'Beeg', match: /(^|\.)beeg\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: 'YourPorn', match: /(^|\.)sxyprn\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: 'SpankBang', match: /(^|\.)spankbang\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: 'XMoviesForYou', match: /(^|\.)xmoviesforyou\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: 'PornTrex', match: /(^|\.)porntrex\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: 'YouJizz', match: /(^|\.)youjizz\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: 'PornOne', match: /(^|\.)(pornone|vporn)\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: '3Movs', match: /(^|\.)3movs\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: 'Tube8', match: /(^|\.)tube8\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: 'PornDig', match: /(^|\.)porndig\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: 'CumLouder', match: /(^|\.)cumlouder\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: 'TXXX', match: /(^|\.)txxx\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    { name: 'PornDoe', match: /(^|\.)porndoe\.com$/, features: ADBLOCK_ONLY, limits: TUBE_LIMITS },
    // Hanime1:動畫轉貼站
    // 不開下載:內容多為動畫公司作品的轉載
    { name: 'Hanime1', match: /(^|\.)hanime1\.me$/,
      features: ['security', 'saveData', 'hideAds', 'autoSkip', 'videoStatus', 'estimator', 'listLabels'],
      limits: ['不提供下載(內容多為動畫公司作品的轉載)', '剛加入,還沒有這個網站專屬的擋廣告規則;有漏掉的廣告請回報'] },
  ];

  const site = SITES.find(s => s.match.test(HOST));
  if (!site) return;
  siteAdPath = site.adPath || null;
  if (site.rules) site.features = ['siteRules', ...site.features];

  // ---- XVideos:影片網址藏在網頁原始碼 ----
  async function findXvSources() {
    const html = document.documentElement.innerHTML;
    const pick = re => { const m = html.match(re); return m ? m[1].replace(/\\\//g, '/') : null; };
    const title = pick(/setVideoTitle\(\s*'([^']*)'/) || document.title;
    const list = [];

    const hls = pick(/setVideoHLS\(\s*'([^']+)'/);
    if (hls) {
      try {
        for (const v of parseMaster(await getData(hls, 'text'), hls)) {
          list.push({ label: v.height + 'p', height: v.height, kind: 'hls', url: v.url, bw: v.bw });
        }
      } catch (e) {}
    }
    const high = pick(/setVideoUrlHigh\(\s*'([^']+)'/);
    const low = pick(/setVideoUrlLow\(\s*'([^']+)'/);
    if (high) list.push({ label: '高畫質(約 360~480p)', height: 480, kind: 'mp4', url: high });
    if (low) list.push({ label: '低畫質(約 240p)', height: 240, kind: 'mp4', url: low });

    list.sort((a, b) => b.height - a.height);
    return { title, list };
  }

  // ---- YouPorn、RedTube(跟 Pornhub 同一家 Aylo):影片清單寫在網頁裡的 mediaDefinition(s) ----
  // 從網頁原始碼找出 "mediaDefinitions": [ … ] 這一段,括號配對後當 JSON 讀
  function readJsonArrayAfter(html, keyRe) {
    const m = keyRe.exec(html);
    if (!m) return null;
    let i = html.indexOf('[', m.index + m[0].length - 1), depth = 0, inStr = false, esc = false;
    if (i < 0) return null;
    for (let j = i; j < html.length && j < i + 200000; j++) {
      const c = html[j];
      if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; }
      if (c === '"') inStr = true;
      else if (c === '[') depth++;
      else if (c === ']' && --depth === 0) {
        try { return JSON.parse(html.slice(i, j + 1)); } catch (e) { return null; }
      }
    }
    return null;
  }

  async function findAyloSources() {
    const html = document.documentElement.innerHTML;
    const og = document.querySelector('meta[property="og:title"]');
    const title = (og && og.content) || document.title;
    const defs = readJsonArrayAfter(html, /["']?mediaDefinitions?["']?\s*[:=]\s*/g) || [];
    const list = [];
    // MP4:清單裡的網址要再問一次,才拿到各畫質(跟 Pornhub 一樣)
    for (const d of defs.filter(d => d && d.format === 'mp4' && d.videoUrl)) {
      try {
        const arr = JSON.parse(await getData(new URL(d.videoUrl, location.href).href, 'text'));
        for (const x of Array.isArray(arr) ? arr : []) {
          const h = +x.quality;
          if (x.videoUrl && h && !list.some(y => y.height === h)) list.push({ label: h + 'p', height: h, kind: 'mp4', url: x.videoUrl });
        }
      } catch (e) {}
      break;
    }
    for (const d of defs) {
      if (!d || d.format !== 'hls' || !d.videoUrl) continue;
      const h = +(Array.isArray(d.quality) ? d.quality[0] : d.quality);
      if (!h || list.some(x => x.height === h)) continue;
      list.push({ label: h + 'p', height: h, kind: 'hls', url: new URL(d.videoUrl, location.href).href });
    }
    list.sort((a, b) => b.height - a.height);
    return { title, list };
  }

  // ---- xHamster:影片資訊在網頁的 window.initials 裡(xplayerSettings.sources)----
  // 網址被網站加密成一串 16 進位字(例如 "050dc4c6…"):
  //   第 1 個位元組=用哪一種算法(1~7),第 2~5 個=起始數字(seed),後面=密文
  //   用算法產生一串「鑰匙」位元組,跟密文逐個 XOR 就還原成網址(跟 yt-dlp 的解法一樣)
  // 還是解不出來時,就給一行「交給電腦版找畫質」(電腦版的 yt-dlp 會處理)
  function xhDecodeHex(hex) {
    if (!/^[0-9a-f]{12,}$/i.test(hex)) return null;
    const b = hex.match(/../g).map(x => parseInt(x, 16));
    let s = b[1] | (b[2] << 8) | (b[3] << 16) | (b[4] << 24);
    const M = Math.imul;                             // 32 位元乘法(跟網站的算法一樣會溢位)
    const ALGO = {
      1: () => (s = (M(s, 1664525) + 1013904223) | 0),
      2: () => { let t = s ^ (s << 13); t ^= t >>> 17; return (s = t ^ (t << 5)); },
      3: () => { s = (s + 0x9e3779b9) | 0; let t = s ^ (s >>> 16); t = M(t, 0x85ebca77); t ^= t >>> 13; t = M(t, 0xc2b2ae3d); return t ^ (t >>> 16); },
      4: () => { s = (s + 0x6d2b79f5) | 0; let t = (s << 7) | (s >>> 25); t = (t + 0x9e3779b9) | 0; t ^= t >>> 11; return M(t, 0x27d4eb2d); },
      5: () => { let t = s ^ (s << 7); t ^= t >>> 9; t ^= t << 8; return (s = (t + 0xa5a5a5a5) | 0); },
      6: () => { s = (M(s, 0x2c9277b5) + 0xac564b05) | 0; const t = s ^ (s >>> 18); return t >>> ((s >>> 27) & 31); },
      7: () => { s = (s + 0x9e3779b9) | 0; let t = s ^ (s << 5); t = M(t, 0x7feb352d); t ^= t >>> 15; return M(t, 0x846ca68b); },
    };
    const next = ALGO[b[0]];
    if (!next) return null;                          // 網站換了新算法 → 交給電腦版
    let out = '';
    for (let i = 5; i < b.length; i++) out += String.fromCharCode(b[i] ^ (next() & 0xff));
    return out;
  }
  // 網址可能是:一般網址、整串都是密文、或只有路徑第一段是密文
  function xhUrl(raw) {
    if (typeof raw !== 'string' || !raw) return null;
    let u = /^[0-9a-f]+$/i.test(raw) ? xhDecodeHex(raw) : raw;
    if (!u || !/^(https?:)?\/\//.test(u)) return null;
    try {
      const p = new URL(u, location.href);
      const m = p.pathname.match(/^\/([0-9a-f]{12,})([/,].+)$/i);
      if (m) { const d = xhDecodeHex(m[1]); if (!d) return null; p.pathname = '/' + d + m[2]; }
      return p.href;
    } catch (e) { return null; }
  }
  async function findXhSources() {
    const ini = PW.initials || {};
    const vm = ini.videoModel || {};
    const title = vm.title || (document.querySelector('meta[property="og:title"]') || {}).content || document.title;
    const srcs = (ini.xplayerSettings && ini.xplayerSettings.sources) || {};
    const list = [];
    const add = item => { if (item.height && !list.some(x => x.height === item.height)) list.push(item); };
    // 先試 h264(Windows 內建播放器都能播),都失敗才用 av1
    const cands = [];
    for (const codec of ['h264', 'av1', 'mp4']) {
      const h = srcs.hls && srcs.hls[codec];
      if (h) cands.push(h.url, h.fallback);
      const std = srcs.standard && srcs.standard[codec];
      for (const x of Array.isArray(std) ? std : []) cands.push({ q: x.quality || x.label, u: x.url }, { q: x.quality || x.label, u: x.fallback });
    }
    if (srcs.hls && !srcs.hls.h264) cands.push(srcs.hls.url, srcs.hls.fallback);
    const tried = new Set();
    for (const c of cands) {
      const u = xhUrl(c && typeof c === 'object' ? c.u : c);
      if (!u || tried.has(u)) continue;
      tried.add(u);
      if (/\.m3u8(\?|$)/.test(u)) {
        if (list.length) continue;                   // 已經有一份畫質清單就夠了(備用網址不用再抓)
        try {
          for (const v of parseMaster(await getData(u, 'text'), u)) add({ label: v.height + 'p', height: v.height, kind: 'hls', url: v.url, bw: v.bw });
        } catch (e) {}
      } else {
        const h = parseInt(c && c.q, 10);
        if (h) add({ label: h + 'p', height: h, kind: 'mp4', url: u });
      }
    }
    list.sort((a, b) => b.height - a.height);
    if (!list.length && (vm.id || ini.xplayerSettings)) {
      list.push({ label: '交給電腦版找畫質', height: 0, kind: 'page', url: '' });
    }
    return { title, list };
  }

  // ---- Pornhub:影片網址在 flashvars 變數裡 ----
  async function findPhSources() {
    const key = Object.keys(PW).find(k => k.startsWith('flashvars_'));
    if (!key) return { title: document.title, list: [] };
    const fv = PW[key];
    const title = fv.video_title || document.title;
    const defs = fv.mediaDefinitions || [];
    const list = [];

    const mp4Def = defs.find(d => d.format === 'mp4' && d.videoUrl);
    if (mp4Def) {
      try {
        const arr = JSON.parse(await getData(mp4Def.videoUrl, 'text'));
        for (const d of arr) {
          if (d.videoUrl) list.push({ label: d.quality + 'p', height: +d.quality, kind: 'mp4', url: d.videoUrl });
        }
      } catch (e) {}
    }
    for (const d of defs) {
      if (d.format !== 'hls' || !d.videoUrl) continue;
      const h = +(Array.isArray(d.quality) ? d.quality[0] : d.quality);
      if (!h || list.some(x => x.height === h)) continue;
      list.push({ label: h + 'p', height: h, kind: 'hls', url: d.videoUrl });
    }

    list.sort((a, b) => b.height - a.height);
    return { title, list };
  }

  /* ==================================================================
   * 第三層:功能模組
   *   early: true → 網頁一開始就啟動(要比廣告程式早)
   *   其他的 → 網頁載入完成後才啟動,而且只在主頁面(不在框框裡)
   * ================================================================== */

  const shared = { adShowing: false };

  const MODULES = {

    /* ---------- 安全防護 ---------- */
    security: {
      label: '安全防護(擋彈窗、跳轉、危險下載)',
      early: true,
      run() {
        // 1. 擋彈出視窗、偷開新分頁
        try {
          ui.realOpen = PW.open;
          PW.open = function (url, ...rest) {
            const u = url ? String(url) : '';
            if (!u || u === 'about:blank' || isAdTarget(u)) { ui.report('擋下彈出視窗', u ? unwrap(u) : null); return null; }
            if (isDangerous(u)) { ui.report('擋下危險檔案下載', null); return null; }
            return ui.realOpen.call(PW, url, ...rest);
          };
        } catch (e) {}

        // 2. 擋廣告連結和危險檔案
        document.addEventListener('click', e => {
          const a = e.target && e.target.closest && e.target.closest('a[href]');
          if (!a || a.closest('.vh-ui')) return;
          const href = a.href;
          if (isDangerous(href)) {
            e.preventDefault(); e.stopImmediatePropagation();
            ui.report('擋下危險檔案下載:' + href.split('/').pop().split('?')[0], null);
          } else if ((a.target === '_blank' && isAdTarget(href)) || isAd(href)) {
            e.preventDefault(); e.stopImmediatePropagation();
            ui.report('擋下廣告連結(會開新分頁到別的網站)', unwrap(href));
          }
        }, true);

        // 2b. 「看不見的連結,用程式自己點一下」開彈窗的招數
        try {
          const realClick = PW.HTMLAnchorElement.prototype.click;
          PW.HTMLAnchorElement.prototype.click = function () {
            const href = this.href || '';
            if (!this.closest('.vh-ui') && !href.startsWith('blob:') &&
                ((this.target === '_blank' && isAdTarget(href)) || isAd(href))) {
              ui.report('擋下隱藏的彈窗連結', unwrap(href));
              return;
            }
            return realClick.call(this);
          };
        } catch (e) {}

        // 3. 擋自動跳轉
        try {
          const nav = PW.navigation;
          if (nav) nav.addEventListener('navigate', e => {
            const url = e.destination && e.destination.url;
            if (!url || !e.cancelable) return;
            if (isDangerous(url)) { e.preventDefault(); ui.report('擋下危險檔案下載', null); }
            else if (isAd(url) || (!e.userInitiated && isAdTarget(url))) { e.preventDefault(); ui.report('擋下自動跳轉', unwrap(url)); }
          });
        } catch (e) {}

        // 4. 擋「允許通知」
        try {
          if (PW.Notification) {
            PW.Notification.requestPermission = function () {
              ui.report('擋下通知權限請求', null);
              return Promise.resolve('denied');
            };
          }
        } catch (e) {}

        document.addEventListener('DOMContentLoaded', () => ui.flush());
      },
    },

    /* ---------- 網站專屬規則(參考 uBlock 規則庫)---------- */
    siteRules: {
      label: '網站專屬擋廣告規則(擋影片前廣告等)',
      early: true,
      run() {
        const R = site.rules;

        // 1. 藏起廣告區塊:網頁一開始就加上 CSS
        if (R.hide && R.hide.length) {
          const style = document.createElement('style');
          style.textContent = R.hide.join(',\n') + ' { display: none !important; }';
          // 網頁剛開始時可能連 <html> 都還沒有,等它出現再加
          const addStyle = () => {
            const root = document.head || document.documentElement;
            if (root) { root.appendChild(style); return true; }
            return false;
          };
          if (!addStyle()) {
            const mo = new MutationObserver(() => { if (addStyle()) mo.disconnect(); });
            mo.observe(document, { childList: true, subtree: true });
          }
        }

        // 2. 設定固定值(網站之後改不掉)
        for (const [k, v] of Object.entries(R.setGlobals || {})) {
          try { Object.defineProperty(PW, k, { get: () => v, set: () => {}, configurable: true }); } catch (e) {}
        }

        // 3. 取消特定的計時器
        if (R.blockTimeout && R.blockTimeout.length) {
          try {
            const realST = PW.setTimeout;
            PW.setTimeout = function (fn, ...rest) {
              let code = '';
              try { code = typeof fn === 'function' ? Function.prototype.toString.call(fn) : String(fn); } catch (e) {}
              if (R.blockTimeout.some(re => re.test(code))) return 0;
              return realST.call(this, fn, ...rest);
            };
          } catch (e) {}
        }

        // 4. 不讓特定的點擊監聽器裝上去
        if (R.blockListeners && R.blockListeners.length) {
          try {
            const realAEL = PW.EventTarget.prototype.addEventListener;
            PW.EventTarget.prototype.addEventListener = function (type, fn, ...rest) {
              if (fn && R.blockListeners.some(b => b.type === type)) {
                let code = '';
                try { code = Function.prototype.toString.call(typeof fn === 'function' ? fn : fn.handleEvent); } catch (e) {}
                if (R.blockListeners.some(b => b.type === type && b.match.test(code))) return;
              }
              return realAEL.call(this, type, fn, ...rest);
            };
          } catch (e) {}
        }

        // 5. 網頁裡直接寫的程式:執行前先檢查
        //    - 含影片設定(flashvars)的:絕對不擋,只把廣告設定改名
        //    - 含廣告關鍵字的:不讓它執行
        // 例:"adRollGlobalConfig": → "vhOff_adRollGlobalConfig":
        const keyRes = (R.renameKeys || []).map(k => ({ key: k, re: new RegExp('(["\']?)' + k + '\\1(\\s*:)', 'g') }));
        const handleScript = sc => {
          if (sc.src || sc.dataset.vhChecked) return;
          sc.dataset.vhChecked = '1';
          const text = sc.textContent;
          if (!text) return;
          if (/flashvars_/.test(text)) {
            let t = text;
            for (const { key, re } of keyRes) t = t.replace(re, '$1vhOff_' + key + '$1$2');
            if (t !== text) {
              sc.textContent = t; ui.saved++; ui.blockedHosts['影片前廣告設定'] = 1;
              logEvent('拿掉影片前廣告設定', '播放器找不到廣告設定,就不會去要影片前廣告', '');
            }
            return;
          }
          if ((R.blockInlineScript || []).some(group => group.every(re => re.test(text)))) {
            sc.type = 'javascript/blocked';
            ui.saved++;
            ui.blockedHosts['網頁內的廣告程式'] = (ui.blockedHosts['網頁內的廣告程式'] || 0) + 1;
            logEvent('不讓網頁內的廣告程式執行', '符合這個網站的擋廣告規則(參考 uBlock Origin 規則庫)', '');
          }
        };
        new MutationObserver(muts => {
          for (const m of muts) for (const n of m.addedNodes) {
            if (n.tagName === 'SCRIPT') handleScript(n);
            else if (n.querySelectorAll) n.querySelectorAll('script:not([src])').forEach(handleScript);
          }
        }).observe(document.documentElement || document, { childList: true, subtree: true });

        // 6. 保險:影片設定如果是用別的方式出現的,也在前幾秒內把廣告設定拿掉
        if (keyRes.length) {
          const started = Date.now();
          const timer = setInterval(() => {
            for (const k of Object.keys(PW)) {
              if (!k.startsWith('flashvars_') || !PW[k] || typeof PW[k] !== 'object') continue;
              for (const key of R.renameKeys) {
                if (key in PW[k]) { try { delete PW[k][key]; ui.blockedHosts['影片前廣告設定'] = 1; } catch (e) {} }
              }
            }
            if (Date.now() - started > 5000) clearInterval(timer);
          }, 20);
        }
      },
    },

    /* ---------- 省流模式 ---------- */
    saveData: {
      label: '省流模式(廣告不下載,網速留給影片)',
      early: true,
      run() {
        // 盜版轉貼站:別的網站放進來的框框幾乎都是廣告,一律不載入
        const strictFrames = site.features.includes('hideAds');
        const KEEP = /(google\.com\/recaptcha|recaptcha\.net|hcaptcha\.com|challenges\.cloudflare\.com)/;
        let pendingUpdate = false;
        const saved = url => {
          try { const b = baseOf(new URL(url, location.href).hostname); ui.blockedHosts[b] = (ui.blockedHosts[b] || 0) + 1; } catch (e) {}
          let why = '已知的廣告網路';
          try {
            const u = new URL(url, location.href);
            if (userBlock.has(baseOf(u.hostname))) why = '在你的個人黑名單裡';
            else if (siteAdPath && baseOf(u.hostname) === HOME && siteAdPath.test(u.pathname)) why = '網站自己放廣告用的網址';
            else if (!AD_RE.test(u.hostname)) why = '別的網站放進來的框框(這個網站只留自己的框框)';
          } catch (e) {}
          logEvent('擋下廣告請求(還沒下載就擋掉)', why, url);
          ui.saved++;
          if (!pendingUpdate) { pendingUpdate = true; setTimeout(() => { pendingUpdate = false; ui.flush(); }, 500); }
        };

        const shouldBlock = (tag, url) =>
          isAd(url) || (tag === 'IFRAME' && strictFrames && isExternal(url) && !KEEP.test(url));

        // 1. 網頁程式設定廣告網址的那一刻就攔下(這時候還沒開始下載)
        const TAGS = ['IFRAME', 'SCRIPT', 'IMG', 'VIDEO', 'AUDIO', 'SOURCE', 'EMBED'];
        const srcsetIsAd = v => String(v).split(',').some(part => isAd(part.trim().split(/\s+/)[0]));
        const hookProp = (Ctor, prop, test) => {
          try {
            const d = Object.getOwnPropertyDescriptor(Ctor.prototype, prop);
            if (!d || !d.set) return;
            Object.defineProperty(Ctor.prototype, prop, {
              get: d.get, configurable: true, enumerable: d.enumerable,
              set(v) { if (test(this, String(v))) { saved(String(v)); return; } d.set.call(this, v); },
            });
          } catch (e) {}
        };
        hookProp(PW.HTMLIFrameElement, 'src', (el, v) => shouldBlock('IFRAME', v));
        hookProp(PW.HTMLScriptElement, 'src', (el, v) => shouldBlock('SCRIPT', v));
        hookProp(PW.HTMLImageElement, 'src', (el, v) => isAd(v));
        hookProp(PW.HTMLImageElement, 'srcset', (el, v) => srcsetIsAd(v));
        hookProp(PW.HTMLMediaElement, 'src', (el, v) => isAd(v));     // 影片前廣告常從這裡來
        hookProp(PW.HTMLSourceElement, 'src', (el, v) => isAd(v));
        hookProp(PW.HTMLSourceElement, 'srcset', (el, v) => srcsetIsAd(v));
        try {
          const realSetAttr = PW.Element.prototype.setAttribute;
          PW.Element.prototype.setAttribute = function (name, value) {
            const n = String(name).toLowerCase();
            if (TAGS.includes(this.tagName) &&
                ((n === 'src' && shouldBlock(this.tagName, String(value))) || (n === 'srcset' && srcsetIsAd(value)))) {
              saved(String(value)); return;
            }
            return realSetAttr.call(this, name, value);
          };
        } catch (e) {}

        // 1b. 網頁程式一次塞一整段 HTML 進來:先把裡面的廣告網址拿掉
        const AD_ATTR = /\s(src|srcset|data-src)\s*=\s*(["'])([^"']*)\2/gi;
        const cleanHtml = html => {
          if (typeof html !== 'string' || html.length > 500000) return html;
          return html.replace(AD_ATTR, (all, attr, q, url) => {
            if (url.split(',').some(part => isAd(part.trim().split(/\s+/)[0]))) { saved(url); return ''; }
            return all;
          });
        };
        try {
          const d = Object.getOwnPropertyDescriptor(PW.Element.prototype, 'innerHTML');
          Object.defineProperty(PW.Element.prototype, 'innerHTML', {
            get: d.get, configurable: true, enumerable: d.enumerable,
            set(v) { d.set.call(this, cleanHtml(v)); },
          });
          const realIAH = PW.Element.prototype.insertAdjacentHTML;
          PW.Element.prototype.insertAdjacentHTML = function (pos, html) { return realIAH.call(this, pos, cleanHtml(html)); };
          const realWrite = PW.document.write;
          PW.document.write = function (...a) { return realWrite.apply(this, a.map(cleanHtml)); };
        } catch (e) {}

        // 2. 網頁原始碼裡直接寫的廣告元素:一出現就拔掉
        const checkOne = el => {
          if (!el || el.nodeType !== 1 || el.closest && el.closest('.vh-ui')) return;
          const tag = el.tagName;
          if (!TAGS.includes(tag)) return;
          const url = el.getAttribute('src') || '';
          const set = el.getAttribute('srcset') || '';
          if (!(url && shouldBlock(tag, url)) && !(set && srcsetIsAd(set))) return;
          if (tag === 'SCRIPT') el.type = 'javascript/blocked';   // 不讓它執行
          el.removeAttribute('src');
          el.removeAttribute('srcset');
          el.remove();
          saved(url || set);
        };
        const observer = new MutationObserver(muts => {
          for (const m of muts) {
            if (m.type === 'attributes') { checkOne(m.target); continue; }
            for (const n of m.addedNodes) {
              checkOne(n);
              if (n.querySelectorAll) n.querySelectorAll('script[src], iframe[src], img[src], img[srcset], video[src], audio[src], source, embed[src]').forEach(checkOne);
            }
          }
        });
        observer.observe(document.documentElement || document, {
          childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'srcset'],
        });

        // 3. 網頁程式偷偷跟廣告伺服器要資料:直接回空的
        try {
          const realFetch = PW.fetch;
          PW.fetch = function (input, ...rest) {
            const url = typeof input === 'string' ? input : (input && input.url) || '';
            if (isAd(url)) { saved(url); return Promise.reject(new TypeError('blocked by Video Helper')); }
            return realFetch.call(this, input, ...rest);
          };
        } catch (e) {}
        try {
          const proto = PW.XMLHttpRequest.prototype;
          const realOpen = proto.open;
          proto.open = function (method, url, ...rest) {
            if (isAd(String(url))) { saved(String(url)); return realOpen.call(this, 'GET', 'data:,', ...rest); }
            return realOpen.call(this, method, url, ...rest);
          };
        } catch (e) {}
        try {
          const realBeacon = PW.navigator.sendBeacon;
          PW.navigator.sendBeacon = function (url, ...rest) {
            if (isAd(String(url))) { saved(String(url)); return true; }
            return realBeacon.call(this, url, ...rest);
          };
        } catch (e) {}

        document.addEventListener('DOMContentLoaded', () => ui.flush());
      },
    },

    /* ---------- 隱藏廣告 ---------- */
    hideAds: {
      label: '隱藏廣告(別的網站放進來的框框、透明點擊層)',
      run() {
        // 驗證碼、登入用的框框不要藏
        const KEEP = /(google\.com\/recaptcha|recaptcha\.net|hcaptcha\.com|challenges\.cloudflare\.com)/;
        let hidden = 0;

        setInterval(() => {
          // 1. 別的網站放進來的框框(橫幅、浮動廣告)
          for (const f of document.querySelectorAll('iframe:not([data-vh])')) {
            f.dataset.vh = '1';
            const src = f.getAttribute('src') || '';
            if (!src || KEEP.test(src) || !isExternal(src)) continue;
            f.style.setProperty('display', 'none', 'important');
            hidden++;
          }

          // 2. 蓋在整個畫面上的透明層(點哪裡都會開廣告)
          for (const el of document.querySelectorAll('body > div, body > a')) {
            if (el.dataset.vh || el.classList.contains('vh-ui')) continue;
            const cs = getComputedStyle(el);
            if (cs.position !== 'fixed' && cs.position !== 'absolute') continue;
            const r = el.getBoundingClientRect();
            const covers = r.width >= innerWidth * 0.8 && r.height >= innerHeight * 0.8;
            const invisible = +cs.opacity < 0.1 || (!el.innerText.trim() && !el.querySelector('img,video,canvas'));
            if (covers && invisible && +cs.zIndex > 100) {
              el.dataset.vh = '1';
              el.style.setProperty('pointer-events', 'none', 'important');
              el.style.setProperty('display', 'none', 'important');
              ui.report('擋下透明廣告點擊層', null);
            }
          }

          if (hidden) { ui.count += hidden; hidden = 0; ui.flush(); }
        }, 1000);
      },
    },

    /* ---------- 自動略過廣告 ---------- */
    autoSkip: {
      label: '自動略過影片前廣告',
      run() {
        const skipWords = ['skip', '跳過', '跳过', '略過', '略过'];
        const adTexts = ['ad', '廣告', '广告'];

        const realClick = el => {
          ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach(type => {
            el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
          });
        };
        // 假的跳過按鈕:按下去會去別的網站
        const isFake = el => {
          const a = el.closest('a[href]');
          return !!(a && (isExternal(a.href) || isDangerous(a.href)));
        };

        setInterval(() => {
          let found = false;
          for (const el of document.querySelectorAll('button, div, span, a')) {
            if (el.offsetParent === null || el.closest('.vh-ui')) continue;
            const text = (el.innerText || '').trim().toLowerCase();
            if (!text || text.length > 15) continue;
            if (skipWords.some(w => text.includes(w)) && !/\d/.test(text) && !isFake(el)) realClick(el);
            if (el.children.length === 0 && adTexts.includes(text)) found = true;
          }
          shared.adShowing = found;

          if (found) {
            for (const v of document.querySelectorAll('video')) {
              if (v.duration && v.duration < 120 && v.currentTime < v.duration - 0.5) {
                v.muted = true;
                v.currentTime = v.duration;
              }
            }
          }
        }, 300);
      },
    },

    /* ---------- 影片狀態與載入品質紀錄 ----------
     * 聽播放器的事件,知道影片現在是:載入中、播放中、緩衝卡住、暫停、出錯
     * 「開始播放要多久」量的是:正片開始載入(loadstart)→ 可以開始播(canplay)
     *   這段時間跟你什麼時候按播放無關,也不含廣告
     * 量到後跟當時的預估比,記下比例(只存數字,依網站分開);評估框用最近 20 次的中位數校正預估
     */
    videoStatus: {
      label: '影片狀態與載入品質紀錄(只存數字,可清除)',
      early: true,                 // 網頁一開始就聽,才量得到正片什麼時候開始載入
      run() {
        if (!IS_TOP) return;
        const vq = shared.vq = { state: 'searching', stalls: 0, stallMs: 0, startup: null, estAtStart: null, error: '' };
        const ERR = { 1: '被中斷', 2: '網路錯誤', 3: '解碼錯誤', 4: '格式不支援或網址失效' };
        const info = new WeakMap();          // 每個 <video>:最近一次開始載入的時間
        let stallFrom = 0;
        const isMain = v => isFinite(v.duration) && v.duration > 120;

        const on = (v, type) => {
          const now = performance.now();
          const inf = info.get(v);
          if (type === 'loadstart') { inf.loadstart = now; return; }
          if (!isMain(v)) return;                             // 廣告、預覽小影片不管
          if (type === 'canplay' && vq.startup === null) {
            vq.startup = (now - inf.loadstart) / 1000;
            vq.estAtStart = startupEstimate(v.videoHeight) || shared.startEstRaw || null;
            if (vq.estAtStart) recordStartup(vq.startup / vq.estAtStart);
            if (vq.state === 'searching' || vq.state === 'loading') vq.state = v.paused ? 'ready' : 'playing';
          } else if (type === 'playing') {
            if (stallFrom) { vq.stallMs += now - stallFrom; stallFrom = 0; }
            vq.state = 'playing';
          } else if (type === 'waiting') {
            if (vq.state !== 'playing' && vq.state !== 'buffering') return;   // 還沒開始播時的等待不算卡頓
            if (!stallFrom) { stallFrom = now; vq.stalls++; }
            vq.state = 'buffering';
          } else if (type === 'pause') {
            if (!v.ended && vq.state !== 'error') vq.state = 'paused';
          } else if (type === 'ended') {
            vq.state = 'ended';
          } else if (type === 'error') {
            vq.state = 'error';
            vq.error = ERR[v.error && v.error.code] || '未知錯誤';
          }
        };

        const attach = v => {
          if (info.has(v)) return;
          // 太晚才找到(網頁已經在播了)→ 這次不記錄開始時間,避免記到錯的數字
          const late = v.readyState >= 3;
          info.set(v, { loadstart: performance.now() });
          for (const t of ['loadstart', 'canplay', 'playing', 'waiting', 'pause', 'ended', 'error']) {
            v.addEventListener(t, () => on(v, t));
          }
          if (late && isMain(v)) {
            if (vq.startup === null) vq.startup = -1;
            vq.state = v.paused ? 'paused' : 'playing';
          }
        };

        const scan = () => {
          document.querySelectorAll('video').forEach(attach);
          if (vq.state === 'searching' && [...document.querySelectorAll('video')].some(isMain)) vq.state = 'loading';
        };
        new MutationObserver(scan).observe(document, { childList: true, subtree: true });
        scan();
        setInterval(scan, 1000);
      },
    },

    /* ---------- 影片頁評估框 ---------- */
    estimator: {
      label: '載入時間評估框(右上角)',
      run() {
        const getMain = () => {
          const v = [...document.querySelectorAll('video')].find(x => isFinite(x.duration) && x.duration > 120);
          if (v) return { sec: v.duration, height: v.videoHeight, fromPage: false };
          const meta = document.querySelector(
            'meta[property="video:duration"], meta[property="og:duration"], meta[itemprop="duration"]');
          if (meta) {
            const s = parseDur(meta.content);
            if (s > 120) return { sec: s, height: 0, fromPage: true };
          }
          for (const sc of document.querySelectorAll('script[type="application/ld+json"]')) {
            const m = sc.textContent.match(/"duration"\s*:\s*"([^"]+)"/);
            const s = m ? parseDur(m[1]) : 0;
            if (s > 120) return { sec: s, height: 0, fromPage: true };
          }
          return null;
        };

        let collapsed = false;
        const box = document.createElement('div');
        box.className = 'vh-ui';
        box.style.cssText = 'position:fixed;top:10px;right:20px;z-index:2147483647;background:rgba(0,0,0,.8);' +
          'color:#fff;padding:8px 12px;border-radius:8px;font:14px/1.6 sans-serif;cursor:move;' +
          'white-space:nowrap;display:none;touch-action:none;user-select:none;';
        box.title = '點一下收合 / 展開;按住可以拖到別的位置(雙擊還原位置)';
        document.body.appendChild(box);

        // 位置:使用者拖過就用拖的位置;沒拖過就自動放在網站「固定在上方的選單」下面,不擋到它
        let pos = store.get('estPos', null);             // { right, top }
        const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
        const headerBottom = () => {
          let bottom = 0;
          for (const x of [PW.innerWidth - 40, PW.innerWidth - 200]) {
            for (const el of document.elementsFromPoint(x, 15)) {
              if (el === box || el.closest('.vh-ui') || el === document.body || el === document.documentElement) continue;
              let e = el;
              while (e && e !== document.body) {
                const p = getComputedStyle(e).position;
                if (p === 'fixed' || p === 'sticky') {
                  const r = e.getBoundingClientRect();
                  if (r.top <= 5 && r.bottom < 220) bottom = Math.max(bottom, r.bottom);
                  break;
                }
                e = e.parentElement;
              }
            }
          }
          return bottom;
        };
        const place = () => {
          const w = box.offsetWidth || 200, h = box.offsetHeight || 40;
          const right = pos ? pos.right : 20;
          const top = pos ? pos.top : headerBottom() + 10;
          box.style.right = clamp(right, 0, PW.innerWidth - w) + 'px';
          box.style.top = clamp(top, 0, PW.innerHeight - h) + 'px';
        };
        let drag = null;
        box.addEventListener('pointerdown', e => {
          const r = box.getBoundingClientRect();
          drag = { x: e.clientX, y: e.clientY, right: PW.innerWidth - r.right, top: r.top, moved: false };
          try { box.setPointerCapture(e.pointerId); } catch (err) {}
        });
        box.addEventListener('pointermove', e => {
          if (!drag) return;
          const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
          if (!drag.moved && Math.hypot(dx, dy) < 5) return;
          drag.moved = true;
          pos = { right: drag.right - dx, top: drag.top + dy };
          place();
        });
        box.addEventListener('pointerup', () => {
          if (!drag) return;
          if (drag.moved) store.set('estPos', pos);
          else collapsed = !collapsed;                  // 沒有拖 → 當成點一下
          drag = null;
        });
        box.addEventListener('dblclick', () => { pos = null; store.set('estPos', null); place(); });

        setInterval(() => {
          const main = getMain();
          if (!main) { box.style.display = 'none'; return; }
          box.style.display = 'block';
          place();
          if (collapsed) { box.textContent = '⏱'; return; }

          const speed = measureSpeed();
          if (!speed) { box.textContent = '量測網速中…'; return; }

          const need = bitrateFor(main.height);
          const ratio = speed.mbps / need;
          const ratingText = ratio >= 2 ? '順暢' : ratio >= 1.2 ? '可能偶爾卡' : '會卡,建議降畫質';
          const header = shared.adShowing ? '📺 廣告中,以下為預估<br>' : main.fromPage ? '📄 影片還沒開始,以下為預估<br>' : '';

          // 開始播放的預估:原始值存起來給「影片狀態」記錄;有足夠紀錄就校正
          const raw = startupEstimate(main.height) || (need * 5 / speed.mbps + 0.5);
          shared.startEstRaw = raw;
          const [f, n] = startupFactor();
          const vq = shared.vq;
          let startLine = `開始播放:約 ${fmt(raw * (f || 1))}` + (f ? `(依你過去 ${n} 次校正)` : '');
          if (vq && vq.startup !== null && vq.startup >= 0) {
            startLine = `實際開始播放:${fmt(vq.startup)}` + (vq.estAtStart ? `(預估 ${fmt(vq.estAtStart * (f || 1))})` : '');
          }

          box.innerHTML = statusLine(vq) + header +
            `網速:${speed.mbps.toFixed(1)} Mbps(${speed.source})<br>` +
            `影片長度:${fmt(main.sec)}<br>` +
            `畫質:${main.height ? main.height + 'p' : '以 720p 預估'}<br>` +
            `${startLine}<br>` +
            `整部載完:約 ${fmt(need * main.sec / speed.mbps)}<br>` +
            `${ratingOf(ratio)} ${ratingText}|建議最高 ${recommend(speed.mbps)}` +
            (ui.saved ? `<br>🚫 已擋 ${ui.saved} 個廣告請求,網速留給影片` : '');
        }, 1000);
      },
    },

    /* ---------- 列表頁縮圖標示 ---------- */
    listLabels: {
      label: '列表頁縮圖顯示載入時間',
      run() {
        const parseThumbTime = t => {
          let m = t.match(/^(?:(\d+):)?(\d{1,2}):(\d{2})$/);
          if (m) return (+m[1] || 0) * 3600 + (+m[2]) * 60 + (+m[3]);
          m = t.match(/^(?:(\d+)\s*h)?\s*(?:(\d+)\s*min)?$/i);
          if (m && (m[1] || m[2])) return (+m[1] || 0) * 3600 + (+m[2] || 0) * 60;
          m = t.match(/^(\d+)\s*sec$/i);
          return m ? +m[1] : 0;
        };

        setInterval(() => {
          const speed = measureSpeed();
          if (!speed) return;
          const need = BITRATE[720];
          const ratio = speed.mbps / need;

          for (const el of document.querySelectorAll('span, div, var')) {
            if (el.dataset.vhDone || el.children.length > 0) continue;
            if (el.closest('[class*="player"], [id*="player"], .vh-ui')) continue;
            const sec = parseThumbTime((el.innerText || '').trim());
            if (sec < 60) continue;

            el.dataset.vhDone = '1';
            const fullSec = need * sec / speed.mbps;
            const tag = document.createElement('span');
            tag.textContent = ` ${ratingOf(ratio)} ${fullSec < 60 ? Math.round(fullSec) + '秒' : Math.round(fullSec / 60) + '分'}`;
            tag.style.cssText = 'font-size:11px;margin-left:4px;';
            tag.title = `以 720p、${speed.mbps.toFixed(1)} Mbps 估算整部載完時間`;
            el.appendChild(tag);
          }
        }, 1500);
      },
    },

    /* ---------- 一鍵下載 ---------- */
    download: {
      label: '一鍵下載(右下角)',
      async run() {
        if (!site.isVideoPage || !site.isVideoPage()) return;
        const src = await site.findSources();
        if (src && src.list.length) buildDownloadUI(src);
      },
    },
  };

  /* ---------- 下載模組用到的工具 ---------- */

  function safeName(s) { return s.replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100) || 'video'; }
  function absUrl(u, base) { return new URL(u, base).href; }

  // 讀「畫質總表」
  function parseMaster(text, base) {
    const lines = text.split(/\r?\n/);
    const out = [];
    for (let i = 0; i < lines.length; i++) {
      if (!lines[i].startsWith('#EXT-X-STREAM-INF')) continue;
      const res = lines[i].match(/RESOLUTION=\d+x(\d+)/);
      const bw = lines[i].match(/BANDWIDTH=(\d+)/);
      const name = lines[i].match(/NAME="?(\d+)p?/);
      let j = i + 1;
      while (j < lines.length && (!lines[j].trim() || lines[j].startsWith('#'))) j++;
      if (j < lines.length) {
        out.push({ url: absUrl(lines[j].trim(), base), height: res ? +res[1] : (name ? +name[1] : 0), bw: bw ? +bw[1] : 0 });
      }
    }
    return out;
  }

  // 讀「片段清單」
  function parseMedia(text, base) {
    const segs = [];
    let dur = 0, init = null, encrypted = false;
    for (const raw of text.split(/\r?\n/)) {
      const l = raw.trim();
      if (l.startsWith('#EXTINF:')) dur += parseFloat(l.slice(8)) || 0;
      else if (l.startsWith('#EXT-X-MAP')) { const m = l.match(/URI="([^"]+)"/); if (m) init = absUrl(m[1], base); }
      else if (l.startsWith('#EXT-X-KEY') && !/METHOD=NONE/.test(l)) encrypted = true;
      else if (l && !l.startsWith('#')) segs.push(absUrl(l, base));
    }
    return { segs, dur, init, encrypted };
  }

  async function resolveMedia(url, bw = 0) {
    const text = await getData(url, 'text');
    if (text.includes('#EXT-X-STREAM-INF')) {
      const v = parseMaster(text, url).sort((a, b) => b.height - a.height)[0];
      return resolveMedia(v.url, v.bw || bw);
    }
    return Object.assign(parseMedia(text, url), { bw });
  }

  async function estimateSize(s) {
    if (s.kind === 'page') return { bytes: 0, exact: false };
    if (s.kind === 'mp4') return { bytes: await getSize(s.url), exact: true };
    try {
      s.media = await resolveMedia(s.url, s.bw || 0);
      const bps = s.media.bw || bitrateFor(s.height) * 1e6;
      return { bytes: bps * s.media.dur / 8, exact: false };
    } catch (e) {
      return { bytes: 0, exact: false };
    }
  }

  // 用 mux.js 把 .ts 片段轉成 .mp4(失敗就回傳 null,改存 .ts)
  /* 把「分段式 MP4」(mux.js 轉出來的、或網站本身給的 fMP4 切片)整理成「一般的 MP4」
   *
   * 分段式 MP4:檔頭幾乎是空的,影像聲音一段一段放,每段自己帶小目錄(moof)。
   *   有些播放器(例如 Windows 媒體播放器)對這種檔案很挑:長度顯示 00:00:00、不能拖、甚至打不開。
   * 一般的 MP4:檔頭(moov)有完整的目錄:每一格畫面多大、多長、哪些是關鍵畫面、放在檔案哪裡。
   *   這就是 ffmpeg 平常輸出的格式,所有播放器都能開。
   *
   * 做法:讀每一段的小目錄 → 合成一份完整目錄 → 輸出 [檔頭, 目錄, 資料區標頭, 各段資料]。
   *   影片資料本身只是「指過去」,不會再複製一份,所以大檔也不會多吃一倍記憶體。
   */
  function defragmentMp4(parts) {
    const u32 = (b, i) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
    const s32 = (b, i) => (b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3];
    const u64 = (b, i) => u32(b, i) * 4294967296 + u32(b, i + 4);
    const typeAt = (b, i) => String.fromCharCode(b[i + 4], b[i + 5], b[i + 6], b[i + 7]);
    function list(b, start, end) {
      const out = [];
      for (let i = start; i + 8 <= end;) {
        let size = u32(b, i), hdr = 8;
        if (size === 1) { size = u64(b, i + 8); hdr = 16; } else if (size === 0) size = end - i;
        if (size < hdr || i + size > end) break;
        out.push({ t: typeAt(b, i), s: i, e: i + size, c: i + hdr });
        i += size;
      }
      return out;
    }
    const child = (b, box, t) => list(b, box.c, box.e).find(x => x.t === t) || null;
    const raw = (b, box) => b.subarray(box.s, box.e);

    // ---- 讀檔頭(init):每條軌道的基本資料 ----
    const init = parts[0];
    const moov = list(init, 0, init.length).find(x => x.t === 'moov');
    if (!moov) throw new Error('找不到 moov');
    const mvhd = child(init, moov, 'mvhd');
    const movieTs = u32(init, mvhd.c + (init[mvhd.c] === 1 ? 20 : 12));
    const tracks = new Map();
    for (const trak of list(init, moov.c, moov.e).filter(x => x.t === 'trak')) {
      const tkhd = child(init, trak, 'tkhd'), mdia = child(init, trak, 'mdia');
      const mdhd = child(init, mdia, 'mdhd'), hdlr = child(init, mdia, 'hdlr'), minf = child(init, mdia, 'minf');
      const stbl = child(init, minf, 'stbl');
      const id = u32(init, tkhd.c + (init[tkhd.c] === 1 ? 20 : 12));
      // 原本的「影音對齊」設定(edit list):例如「影像晚 66 毫秒開始」,要原樣帶過去
      let edits = null;
      const edts = child(init, trak, 'edts'), elst = edts && child(init, edts, 'elst');
      if (elst) {
        const v1 = init[elst.c] === 1, n = u32(init, elst.c + 4);
        edits = [];
        for (let k = 0, q = elst.c + 8; k < n; k++) {
          const dur = v1 ? u64(init, q) : u32(init, q);
          const mt = v1 ? (s32(init, q + 8) * 4294967296 + u32(init, q + 12)) : s32(init, q + 4);
          q += v1 ? 16 : 8;
          edits.push({ dur, mt, rate: init.subarray(q, q + 4) });
          q += 4;
        }
      }
      tracks.set(id, {
        edits,
        id, tkhd: raw(init, tkhd), mdhd: raw(init, mdhd), hdlr: raw(init, hdlr),
        mhd: raw(init, child(init, minf, 'vmhd') || child(init, minf, 'smhd') || child(init, minf, 'nmhd')),
        dinf: child(init, minf, 'dinf') ? raw(init, child(init, minf, 'dinf')) : null,
        stsd: raw(init, child(init, stbl, 'stsd')),
        ts: u32(init, mdhd.c + (init[mdhd.c] === 1 ? 20 : 12)),
        video: typeAt(init, hdlr.c + 4) === 'vide',
        def: { dur: 0, size: 0, flags: 0 },
        sizes: [], durs: [], ctos: [], sync: [], chunks: [], firstDts: null,
      });
    }
    const mvex = child(init, moov, 'mvex');
    if (mvex) for (const trex of list(init, mvex.c, mvex.e).filter(x => x.t === 'trex')) {
      const tr = tracks.get(u32(init, trex.c + 4));
      if (tr) tr.def = { dur: u32(init, trex.c + 12), size: u32(init, trex.c + 16), flags: u32(init, trex.c + 20) };
    }

    // ---- 讀每一段的小目錄(moof)----
    const chunks = [];                                   // 依出現順序:{ track, data }
    for (let pi = 1; pi < parts.length; pi++) {
      const b = parts[pi];
      for (const moof of list(b, 0, b.length).filter(x => x.t === 'moof')) {
        let prevEnd = moof.s;
        for (const traf of list(b, moof.c, moof.e).filter(x => x.t === 'traf')) {
          const tfhd = child(b, traf, 'tfhd');
          const fl = u32(b, tfhd.c) & 0xffffff;
          const tr = tracks.get(u32(b, tfhd.c + 4));
          if (!tr) continue;
          let q = tfhd.c + 8;
          let base = (fl & 0x20000) ? moof.s : prevEnd;
          if (fl & 0x1) { base = u64(b, q); q += 8; }
          if (fl & 0x2) q += 4;
          let dDur = tr.def.dur, dSize = tr.def.size, dFlags = tr.def.flags;
          if (fl & 0x8) { dDur = u32(b, q); q += 4; }
          if (fl & 0x10) { dSize = u32(b, q); q += 4; }
          if (fl & 0x20) { dFlags = u32(b, q); q += 4; }
          const tfdt = child(b, traf, 'tfdt');
          if (tfdt && tr.firstDts === null) tr.firstDts = b[tfdt.c] === 1 ? u64(b, tfdt.c + 4) : u32(b, tfdt.c + 4);
          for (const trun of list(b, traf.c, traf.e).filter(x => x.t === 'trun')) {
            const v1 = b[trun.c] === 1, tf = u32(b, trun.c) & 0xffffff, n = u32(b, trun.c + 4);
            let r = trun.c + 8, ptr = base;
            if (tf & 0x1) { ptr = base + s32(b, r); r += 4; }
            let firstFlags = null;
            if (tf & 0x4) { firstFlags = u32(b, r); r += 4; }
            const start = ptr;
            for (let k = 0; k < n; k++) {
              const d = (tf & 0x100) ? u32(b, r) : dDur; if (tf & 0x100) r += 4;
              const sz = (tf & 0x200) ? u32(b, r) : dSize; if (tf & 0x200) r += 4;
              let sf = (tf & 0x400) ? u32(b, r) : dFlags; if (tf & 0x400) r += 4;
              if (k === 0 && firstFlags !== null) sf = firstFlags;
              const cto = (tf & 0x800) ? (v1 ? s32(b, r) : u32(b, r)) : 0; if (tf & 0x800) r += 4;
              tr.durs.push(d); tr.sizes.push(sz); tr.ctos.push(cto); tr.sync.push(!(sf & 0x10000));
              ptr += sz;
            }
            if (ptr > b.length) throw new Error('片段資料不完整');
            if (n) { const c = { track: tr, data: b.subarray(start, ptr), count: n }; tr.chunks.push(c); chunks.push(c); }
            prevEnd = ptr;
          }
        }
      }
    }
    if (!chunks.length) throw new Error('沒有影片資料');

    // ---- 寫出一般的 MP4 ----
    const enc = s => [...s].map(c => c.charCodeAt(0));
    const cat = arrs => { const n = arrs.reduce((a, x) => a + x.length, 0); const o = new Uint8Array(n); let i = 0; for (const x of arrs) { o.set(x, i); i += x.length; } return o; };
    const be32 = v => [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255];
    const be64 = v => [...be32(Math.floor(v / 4294967296)), ...be32(v >>> 0)];
    const box = (t, ...kids) => { const body = cat(kids.map(k => k instanceof Uint8Array ? k : new Uint8Array(k))); return cat([new Uint8Array([...be32(body.length + 8), ...enc(t)]), body]); };
    const full = (t, v, f, ...kids) => box(t, [v, (f >>> 16) & 255, (f >>> 8) & 255, f & 255], ...kids);
    const rle = arr => { const out = []; for (const x of arr) { const l = out[out.length - 1]; if (l && l[1] === x) l[0]++; else out.push([1, x]); } return out; };
    const setDur = (src, off32, off64, value) => {            // 複製一個 box 並改掉它的長度欄位
      const b = src.slice(); const v1 = b[8] === 1;
      b.fill(0, 12, v1 ? 28 : 20);                            // 建立/修改時間歸零(mux.js 寫的是 1904 年)
      if (v1) b.set(be64(value), off64); else b.set(be32(Math.min(value, 0xffffffff)), off32);
      return b;
    };

    const mdatPayload = chunks.reduce((a, c) => a + c.data.length, 0);
    const big = mdatPayload > 0xfff00000;                     // 超過 4 GB:用 64 位元的位置
    let movieDur = 0;
    const buildMoov = offsets => {
      const traks = [];
      for (const tr of tracks.values()) {
        if (!tr.sizes.length) continue;
        const mediaDur = tr.durs.reduce((a, x) => a + x, 0);
        const lead = tr.firstDts ? Math.round(tr.firstDts * movieTs / tr.ts) : 0;   // 這條軌道晚開始的時間
        // 影音對齊:原本有 edit list 就沿用(重新算長度),沒有的話用「晚開始的時間」補一段空白
        let edits = null;
        if (tr.edits && tr.edits.length) {
          edits = tr.edits.map(x => ({ ...x }));
          for (const x of edits) {
            if (x.mt >= 0 && x.dur === 0) x.dur = Math.max(0, Math.round((mediaDur - x.mt) * movieTs / tr.ts));
          }
        } else if (lead > 0) {
          edits = [{ dur: lead, mt: -1, rate: new Uint8Array([0, 1, 0, 0]) },
                   { dur: Math.round(mediaDur * movieTs / tr.ts), mt: 0, rate: new Uint8Array([0, 1, 0, 0]) }];
        }
        const trackDur = edits ? edits.reduce((a, x) => a + x.dur, 0) : Math.round(mediaDur * movieTs / tr.ts);
        movieDur = Math.max(movieDur, trackDur);
        const stbl = [tr.stsd];
        const stts = rle(tr.durs);                                                    // 每格畫面的長度(連續相同的合併)
        stbl.push(full('stts', 0, 0, [...be32(stts.length), ...stts.flatMap(([n, d]) => [...be32(n), ...be32(d)])]));
        if (tr.ctos.some(x => x !== 0)) {
          const ct = rle(tr.ctos), neg = tr.ctos.some(x => x < 0);
          stbl.push(full('ctts', neg ? 1 : 0, 0, [...be32(ct.length), ...ct.flatMap(([n, o]) => [...be32(n), ...be32(o >>> 0)])]));
        }
        if (tr.video && tr.sync.some(x => !x)) {
          const idx = []; tr.sync.forEach((x, i) => { if (x) idx.push(i + 1); });
          stbl.push(full('stss', 0, 0, [...be32(idx.length), ...idx.flatMap(be32)]));
        }
        const sc = []; tr.chunks.forEach((c, i) => { const l = sc[sc.length - 1]; if (!l || l[1] !== c.count) sc.push([i + 1, c.count]); });
        stbl.push(full('stsc', 0, 0, [...be32(sc.length), ...sc.flatMap(([f, n]) => [...be32(f), ...be32(n), ...be32(1)])]));
        stbl.push(full('stsz', 0, 0, [...be32(0), ...be32(tr.sizes.length), ...tr.sizes.flatMap(be32)]));
        const offs = tr.chunks.map(c => offsets.get(c));
        stbl.push(big ? full('co64', 0, 0, [...be32(offs.length), ...offs.flatMap(be64)])
                      : full('stco', 0, 0, [...be32(offs.length), ...offs.flatMap(be32)]));
        const minf = box('minf', tr.mhd, ...(tr.dinf ? [tr.dinf] : []), box('stbl', ...stbl));
        const mdia = box('mdia', setDur(tr.mdhd, 24, 32, mediaDur), tr.hdlr, minf);
        const parts2 = [setDur(tr.tkhd, 28, 36, trackDur)];
        if (edits) {
          parts2.push(box('edts', full('elst', 0, 0, [...be32(edits.length),
            ...edits.flatMap(x => [...be32(Math.min(x.dur, 0xffffffff)), ...be32(x.mt >>> 0), ...x.rate])])));
        }
        traks.push(box('trak', ...parts2, mdia));
      }
      const mv = init.subarray(mvhd.s, mvhd.e);
      return box('moov', setDur(mv, 24, 32, movieDur), ...traks);
    };

    const ftyp = box('ftyp', enc('isom'), be32(512), enc('isom'), enc('iso2'), enc('avc1'), enc('mp41'));
    // 先用假的位置算出目錄多大,再填真正的位置(目錄放在資料前面,播放器一打開就讀得到)
    const fake = new Map(chunks.map(c => [c, 0]));
    const moovSize = buildMoov(fake).length;
    const mdatHdr = big ? new Uint8Array([...be32(1), ...enc('mdat'), ...be64(mdatPayload + 16)])
                        : new Uint8Array([...be32(mdatPayload + 8), ...enc('mdat')]);
    let pos = ftyp.length + moovSize + mdatHdr.length;
    const real = new Map();
    for (const c of chunks) { real.set(c, pos); pos += c.data.length; }
    const moovOut = buildMoov(real);
    if (moovOut.length !== moovSize) throw new Error('目錄大小不一致');
    return [ftyp, moovOut, mdatHdr, ...chunks.map(c => c.data)];
  }

  function tsToMp4(parts) {
    const mux = typeof muxjs !== 'undefined' ? muxjs : (window.muxjs || PW.muxjs);
    if (!mux) return null;
    try {
      const out = [];
      let gotInit = false;
      const tm = new mux.mp4.Transmuxer();
      tm.on('data', seg => {
        if (!gotInit) { out.push(new Uint8Array(seg.initSegment)); gotInit = true; }
        out.push(new Uint8Array(seg.data));
      });
      for (const p of parts) { tm.push(p); tm.flush(); }
      return gotInit ? out : null;
    } catch (e) {
      return null;
    }
  }

  // 整理成一般的 MP4;萬一失敗,就存原本的分段式 MP4(VLC 一定能播)
  function toNormalMp4(parts) {
    try { return defragmentMp4(parts); }
    catch (e) { console.warn('[Video Helper] 整理 MP4 失敗,改存分段式', e); return parts; }
  }

  function saveBlob(parts, type, fileName) {
    const a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob(parts, { type }));
    a.download = fileName;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(a.href), 60000);
  }

  // 切片下載:同時抓 6 段,失敗重試 3 次
  async function downloadHls(s, baseName, onProgress, onStage) {
    const media = s.media || await resolveMedia(s.url, s.bw || 0);
    if (media.encrypted) throw new Error('這部影片有加密,目前不支援');
    if (!media.segs.length) throw new Error('找不到影片片段');

    const total = media.segs.length;
    const parts = new Array(total);
    let done = 0, next = 0;

    async function fetchSeg(i) {
      for (let t = 0; t < 3; t++) {
        try { return new Uint8Array(await getData(media.segs[i], 'arraybuffer')); }
        catch (e) {
          if (t === 2) throw new Error(`第 ${i + 1} 段下載失敗`);
          await new Promise(r => setTimeout(r, 1000));
        }
      }
    }
    async function worker() {
      while (next < total) {
        const i = next++;
        parts[i] = await fetchSeg(i);
        onProgress(++done, total);
      }
    }
    await Promise.all(Array.from({ length: 6 }, worker));

    // 片段本身就是 MP4 格式 → 直接接起來
    if (media.init) {
      parts.unshift(new Uint8Array(await getData(media.init, 'arraybuffer')));
      saveBlob(toNormalMp4(parts), 'video/mp4', baseName + '.mp4');
      return baseName + '.mp4';
    }

    // .ts 片段 → 轉成 MP4
    onStage('轉換成 MP4 中…');
    await new Promise(r => setTimeout(r, 50));
    const mp4 = tsToMp4(parts);
    if (mp4) {
      saveBlob(toNormalMp4(mp4), 'video/mp4', baseName + '.mp4');
      return baseName + '.mp4';
    }
    saveBlob(parts, 'video/mp2t', baseName + '.ts');
    return baseName + '.ts(轉 MP4 失敗,請用 VLC 播放)';
  }

  /* ---------- 電腦版連動:把影片送進電腦版的下載佇列 ----------
   * 電腦版只聽 127.0.0.1;第一次要輸入電腦版顯示的 6 位數配對碼,之後用配對拿到的密碼。
   * 用暴力猴的 GM_xmlhttpRequest 送(一般網頁的 fetch 送不過去,電腦版也會拒絕一般網頁)。
   */
  const BRIDGE_PORTS = [47321, 47322, 47323, 47324, 47325];

  function bridgeReq(method, port, path, body, token) {
    return new Promise(resolve => {
      const headers = {};
      if (body) headers['Content-Type'] = 'application/json';
      if (token) headers['X-VH-Token'] = token;
      GM_xmlhttpRequest({
        method, url: `http://127.0.0.1:${port}${path}`, headers, timeout: 3000,
        data: body ? JSON.stringify(body) : undefined,
        onload: r => { let d = {}; try { d = JSON.parse(r.responseText || '{}'); } catch (e) {} resolve({ status: r.status, data: d }); },
        onerror: () => resolve({ status: 0, data: {} }),
        ontimeout: () => resolve({ status: 0, data: {} }),
      });
    });
  }

  async function findBridge() {
    const last = store.get('bridgePort', 0);
    for (const port of [last, ...BRIDGE_PORTS.filter(p => p !== last)].filter(Boolean)) {
      const r = await bridgeReq('GET', port, '/ping');
      if (r.status === 200 && r.data.app === 'VideoHelper') { store.set('bridgePort', port); return port; }
    }
    return 0;
  }

  // 送一個請求給電腦版;還沒配對(401)就請使用者輸入配對碼,配好再送一次
  async function bridgeCall(port, path, body) {
    let r = await bridgeReq('POST', port, path, body, store.get('bridgeToken', ''));
    if (r.status === 401) {
      const code = prompt('第一次使用,請輸入電腦版顯示的 6 位數配對碼:\n(電腦版 →「③ 設定」→「🔗 配對瀏覽器…」)');
      if (!code) throw new Error('已取消配對');
      const p = await bridgeReq('POST', port, '/pair', { code: code.replace(/\s/g, '') });
      if (p.status !== 200 || !p.data.token) throw new Error(p.data.error || '配對失敗');
      store.set('bridgeToken', p.data.token);
      r = await bridgeReq('POST', port, path, body, p.data.token);
    }
    return r;
  }

  // 判斷網域是不是廣告(電腦版先查免費的公開廣告清單,有設定金鑰才再問 AI)
  // 只送網域名稱和網站名稱。hosts:每個網域實際連到的完整網址。回傳 {網域: {label, reason, block}}
  async function askAi(domains, hosts) {
    const port = await findBridge();
    if (!port) throw new Error('要先打開電腦版(廣告清單在電腦版裡)');
    const r = await bridgeCall(port, '/classify', { site: site ? site.name : HOST, domains, hosts });
    if (r.status !== 200) throw new Error(r.data.error || `電腦版回應錯誤(${r.status})`);
    return r.data.results || {};
  }

  // 回傳一個 ticket(之後拿來查進度),失敗就丟出錯誤訊息
  // s:畫質清單裡的一項(有影片真正的位址)。一起送過去,電腦版就不用自己再開一次網頁
  //    (有些網站會擋不是瀏覽器的程式開網頁,例如 Pornhub 回 410)
  async function sendToDesktop(s, title) {
    const url = location.href.split('#')[0];
    const height = s.height || null;                 // 0(交給電腦版找畫質)→ 用電腦版的預設畫質
    const port = await findBridge();
    if (!port) {
      try { GM_setClipboard(url); } catch (e) {}
      throw new Error('找不到電腦版。請先打開 Video Helper 電腦版,並在「③ 設定」按「🔗 配對瀏覽器…」。\n(網址已經幫你複製,切到電腦版也會自動偵測)');
    }
    const media = s.url ? new URL(s.url, location.href).href : '';
    const r = await bridgeCall(port, '/enqueue',
      { url, height, media, kind: s.kind, title: String(title || '').slice(0, 300) });
    if (r.status !== 200) throw new Error(r.data.error || `電腦版回應錯誤(${r.status})`);
    return { port, ticket: r.data.ticket };
  }

  // 每 2 秒問一次進度,顯示在 el 裡,直到結束
  function watchDesktop(el, port, ticket) {
    const icon = { '讀取中': '🔎 讀取中', '等待中': '⏳ 排隊中', '下載中': '⬇ 下載中', '完成': '✅ 完成', '失敗': '❌ 失敗', '取消': '■ 已取消' };
    let misses = 0;
    const tick = async () => {
      const r = await bridgeReq('GET', port, `/status?ticket=${ticket}`, null, store.get('bridgeToken', ''));
      if (r.status !== 200) {
        // 剛送出時電腦版可能還沒記好,或電腦版正忙;多等幾次再下結論
        if (++misses <= 5) { el.textContent = '🖥 電腦版:已送出,等電腦版回應…'; setTimeout(tick, 2000); return; }
        el.textContent = r.status === 0 ? '🖥 電腦版:連不上了(可能已關閉)'
          : r.status === 401 ? '🖥 電腦版:需要重新配對'
          : '🖥 電腦版:已送出,請到電腦版「① 下載影片」看進度';
        return;
      }
      misses = 0;
      const d = r.data;
      const prog = d.status === '下載中' ? ` ${d.progress || 0}%` : '';
      el.textContent = `🖥 電腦版:${icon[d.status] || d.status}${prog}` + (d.error ? `(${d.error})` : '');
      if (!['完成', '失敗', '取消'].includes(d.status)) setTimeout(tick, 2000);
    };
    tick();
  }

  function buildDownloadUI(src) {
    const wrap = document.createElement('div');
    wrap.className = 'vh-ui';
    document.body.appendChild(wrap);

    const btn = document.createElement('div');
    btn.textContent = '⬇ 下載';
    btn.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483647;background:#e53935;color:#fff;' +
      'padding:10px 16px;border-radius:24px;font:bold 15px sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.4);';
    const panel = document.createElement('div');
    panel.style.cssText = 'position:fixed;bottom:70px;right:20px;z-index:2147483647;background:rgba(20,20,20,.95);' +
      'color:#fff;padding:12px 14px;border-radius:10px;font:14px/1.7 sans-serif;min-width:260px;' +
      'max-height:70vh;overflow-y:auto;display:none;';
    wrap.append(btn, panel);

    let busy = false;
    const desk = document.createElement('div');     // 顯示電腦版那邊的進度

    btn.addEventListener('click', async () => {
      if (busy || panel.style.display === 'block') {
        panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
        return;
      }
      panel.style.display = 'block';
      panel.textContent = '讀取各畫質大小中…';

      const speed = measureSpeed();
      const mbps = speed ? speed.mbps : 0;
      const results = await Promise.all(src.list.map(estimateSize));

      panel.innerHTML = '<b>選擇畫質</b>';
      src.list.forEach((s, i) => {
        const { bytes, exact } = results[i];
        const sizeText = bytes ? (exact ? '' : '約 ') + fmtSize(bytes) : '大小未知';
        const timeText = bytes && mbps ? '約 ' + fmt(bytes * 8 / (mbps * 1e6)) : '時間未知';
        const typeText = s.kind === 'hls' ? '切片' : s.kind === 'page' ? '電腦版會自動選最高畫質' : 'MP4';

        const row = document.createElement('div');
        row.style.cssText = 'margin-top:6px;padding:6px 8px;background:#333;border-radius:6px;cursor:pointer;';
        row.innerHTML = `<b>${s.label}</b> <small>(${typeText})</small><br><small>${sizeText}|${timeText}</small>`;
        // 「🖥」:送到電腦版下載(不在瀏覽器裡下載)
        const toPc = document.createElement('span');
        toPc.textContent = '🖥 送到電腦版';
        toPc.title = '用電腦版下載:大檔不吃瀏覽器記憶體,可以關掉網頁、在背景下載';
        toPc.style.cssText = 'float:right;margin-left:8px;padding:1px 8px;border-radius:4px;background:#1565c0;font-size:12px;';
        toPc.addEventListener('click', async e => {
          e.stopPropagation();
          desk.textContent = '🖥 送到電腦版中…';
          try {
            const { port, ticket } = await sendToDesktop(s, src.title);
            watchDesktop(desk, port, ticket);
          } catch (err) {
            desk.textContent = '🖥 ' + err.message;
          }
        });
        row.prepend(toPc);
        row.addEventListener('click', () => {
          if (s.kind === 'page') { toPc.click(); return; }   // 這行只能交給電腦版
          const big = bytes > 2e9 ? '\n\n⚠ 檔案很大,下載完成前請不要關閉這個分頁' : '';
          if (!confirm(`確定要下載嗎?\n\n${s.label}(${typeText})\n${sizeText}|${timeText}${big}`)) return;
          startDownload(s, bytes);
        });
        panel.appendChild(row);
      });

      const note = document.createElement('div');
      note.style.cssText = 'margin-top:8px;font-size:12px;opacity:.7;';
      note.textContent = mbps ? `以 ${mbps.toFixed(1)} Mbps(${speed.source})估算` : '尚未量到網速,先播一下影片會比較準';
      panel.appendChild(note);
      desk.style.cssText = 'margin-top:6px;font-size:12px;white-space:pre-line;color:#9cf;';
      panel.appendChild(desk);
    });

    async function startDownload(s, bytes) {
      busy = true;
      const baseName = safeName(src.title) + '_' + s.height + 'p';
      panel.innerHTML = `下載中(請勿關閉分頁):<br><small>${baseName}</small><br><span class="vh-prog">準備中…</span>`;
      const prog = panel.querySelector('.vh-prog');
      const t0 = Date.now();

      const ok = name => { busy = false; panel.innerHTML = `✅ 下載完成<br><small>${name}<br>檔案在「下載」資料夾</small>`; };
      const fail = msg => { busy = false; panel.innerHTML = '❌ 下載失敗:' + msg; };
      const showFrac = (frac, extra) => {
        const el = (Date.now() - t0) / 1000;
        prog.textContent = `${(frac * 100).toFixed(0)}%${extra}|剩約 ${fmt(el / frac - el)}`;
      };

      if (s.kind === 'mp4') {
        GM_download({
          url: s.url,
          name: baseName + '.mp4',
          onprogress: e => {
            const total = e.total || bytes;
            if (!e.loaded) return;
            if (!total) { prog.textContent = '已下載 ' + fmtSize(e.loaded); return; }
            showFrac(e.loaded / total, '');
          },
          onload: () => ok(baseName + '.mp4'),
          onerror: e => fail((e && e.error) || '未知原因'),
        });
      } else {
        try {
          const name = await downloadHls(s, baseName,
            (d, total) => showFrac(d / total, `|${d}/${total} 段`),
            text => { prog.textContent = text; });
          ok(name);
        } catch (e) {
          fail(e.message);
        }
      }
    }
  }

  /* ==================================================================
   * 啟動
   * ================================================================== */

  // 這個分頁暫停時,擋廣告相關的功能先不開(評估框、下載等照常)
  const active = site.features.filter(isOn).filter(n => !(PAUSED && PAUSABLE.includes(n)));
  if (PAUSED && IS_TOP) document.addEventListener('DOMContentLoaded', () => ui.flush());

  // 暴力猴選單:每個功能一個開關
  if (IS_TOP) {
    try {
      for (const name of site.features) {
        const on = isOn(name);
        GM_registerMenuCommand(`${on ? '✅' : '⬜'} ${MODULES[name].label}`, () => {
          settings[name] = !on;
          store.set('settings', settings);
          location.reload();
        });
      }
    } catch (e) {}
  }

  // 🩺 狀態檢查:一眼看出腳本每個部分有沒有正常運作,以及這個網站的功能和已知限制
  ui.actions.health = () => {
    if (ui.panel) { ui.panel.remove(); ui.panel = null; }
    const p = ui.panel = document.createElement('div');
    p.className = 'vh-ui';
    p.style.cssText = 'position:fixed;bottom:60px;left:20px;z-index:2147483647;background:rgba(20,20,20,.97);color:#fff;' +
      'padding:12px 14px;border-radius:10px;font:13px/1.7 sans-serif;width:400px;max-height:75vh;overflow-y:auto;' +
      'box-shadow:0 2px 12px rgba(0,0,0,.5);';
    const add = (html) => { const d = document.createElement('div'); d.innerHTML = html; p.appendChild(d); return d; };
    const esc = t => String(t).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
    const ok = (cond, good, bad, warn) => cond ? `✅ ${good}` : (warn ? `⚠ ${bad}` : `❌ ${bad}`);

    let storeOk = false;
    try { const t = String(Date.now()); store.set('vh_health', t); storeOk = store.get('vh_health') === t; } catch (e) {}
    const hooked = active.includes('saveData') ? !/native code/.test(String(PW.fetch)) : true;
    const hasMux = typeof muxjs !== 'undefined' || !!(window.muxjs || PW.muxjs);
    const sp = measureSpeed();
    const ver = (typeof GM_info !== 'undefined' && GM_info.script) ? GM_info.script.version : '?';

    add(`<b>🩺 狀態檢查(${esc(site.name)})</b>`);
    add(`腳本版本:v${esc(ver)}`);
    add(ok(hooked, '在網頁本身執行(擋廣告的功能才有效)', '沒有在網頁本身執行,擋廣告可能無效(暴力猴設定或版本問題)'));
    add(ok(storeOk, '設定可以存檔', '設定存不了,功能開關和黑名單會不見'));
    if (active.includes('download')) add(ok(hasMux, '切片轉 MP4 的工具已載入', '切片轉 MP4 的工具沒載入,下載會存成 .ts', true));
    add(sp ? `${sp.source === '實測' ? '✅' : '⚠'} 網速:${sp.mbps.toFixed(1)} Mbps(${esc(sp.source)})` : '⚠ 還沒量到網速(播一下影片就會有)');

    add('<br><b>這個網站開啟的功能</b>');
    for (const name of site.features) add(`${isOn(name) ? '✅' : '⬜'} ${esc(MODULES[name].label)}`);
    if (site.limits && site.limits.length) {
      add('<br><b>已知限制</b>');
      for (const l of site.limits) add(`・${esc(l)}`);
    }

    const n = startupSamples(HOME).length;
    const [f] = startupFactor();
    add('<br><b>載入品質紀錄</b>');
    const rec = add(n ? `記錄了 ${n} 次「實際開始播放」的時間` + (f ? `,實際約是預估的 ${f.toFixed(2)} 倍,評估框已自動校正` : '(滿 3 次才會開始校正)')
                      : '還沒有紀錄(看影片時會自動記錄,只存數字)');
    if (n) {
      const clr = document.createElement('span');
      clr.textContent = '清除紀錄';
      clr.style.cssText = 'margin-left:8px;color:#8cf;text-decoration:underline;cursor:pointer;';
      clr.addEventListener('click', () => {
        const all = store.get('vqSamples', {}) || {};
        delete all[HOME];
        store.set('vqSamples', all);
        rec.textContent = '已清除';
      });
      rec.appendChild(clr);
    }
    const close = add('<br><span style="color:#8cf;text-decoration:underline;cursor:pointer">關閉</span>');
    close.addEventListener('click', () => { p.remove(); ui.panel = null; });
    document.body.appendChild(p);
  };

  // 網頁上的「功能開關」面板(不依賴暴力猴選單,手機也能用)
  ui.actions.settings = () => {
    if (ui.panel) { ui.panel.remove(); ui.panel = null; }
    const p = ui.panel = document.createElement('div');
    p.className = 'vh-ui';
    p.style.cssText = 'position:fixed;bottom:60px;left:20px;z-index:2147483647;background:rgba(20,20,20,.97);color:#fff;' +
      'padding:12px 14px;border-radius:10px;font:14px/1.8 sans-serif;width:360px;box-shadow:0 2px 12px rgba(0,0,0,.5);';
    p.innerHTML = `<b>功能開關(${site.name})</b><div style="opacity:.7;font-size:12px">改完會自動重新整理網頁</div>`;
    for (const name of site.features) {
      const row = document.createElement('label');
      row.style.cssText = 'display:flex;gap:8px;align-items:center;cursor:pointer;margin-top:4px;';
      const cb = document.createElement('input');
      cb.type = 'checkbox';
      cb.checked = isOn(name);
      cb.addEventListener('change', () => {
        settings[name] = cb.checked;
        store.set('settings', settings);
        location.reload();
      });
      const txt = document.createElement('span');
      txt.textContent = MODULES[name].label;
      row.append(cb, txt);
      p.appendChild(row);
    }
    const close = document.createElement('div');
    close.textContent = '關閉';
    close.style.cssText = 'margin-top:8px;cursor:pointer;color:#8cf;text-decoration:underline;font-size:13px;';
    close.addEventListener('click', () => { p.remove(); ui.panel = null; });
    p.appendChild(close);
    document.body.appendChild(p);
  };

  if (IS_TOP) {
    try { GM_registerMenuCommand('📋 查看這頁連到的其他網站(封鎖廣告)', () => { ui.flush(); ui.openPanel(); }); } catch (e) {}
  }

  // ---- 診斷:背景自動記錄,使用者點一下就複製 ----
  const diag = { videos: {}, adSeen: [], startedAt: Date.now() };
  if (IS_TOP) {
    const t = () => ((Date.now() - diag.startedAt) / 1000).toFixed(1) + 's';
    const sample = () => {
      document.querySelectorAll('video').forEach((v, i) => {
        const src = v.currentSrc || v.src || '(沒有網址)';
        const key = i + '|' + src.slice(0, 200);
        if (!diag.videos[key]) diag.videos[key] = { i, src: src.slice(0, 300), firstSeen: t(), durations: [] };
        const d = isFinite(v.duration) ? Math.round(v.duration) : String(v.duration);
        const rec = diag.videos[key];
        if (rec.durations[rec.durations.length - 1] !== d) rec.durations.push(d);
      });
      if (shared.adShowing && diag.adSeen.length < 5) diag.adSeen.push(t());
    };
    setInterval(sample, 1000);

    ui.actions.diagnose = () => {
        sample();
        const fvKey = Object.keys(PW).find(k => k.startsWith('flashvars_'));
        const fv = fvKey ? PW[fvKey] : null;
        const report = {
          version: GM_info && GM_info.script && GM_info.script.version,
          manager: GM_info && GM_info.scriptHandler,
          injectInto: GM_info && GM_info.injectInto,
          pageHooked: PW.fetch !== undefined && !/native code/.test(String(PW.fetch)),
          site: site.name, page: location.href.split('?')[0],
          features: active,
          videos: Object.values(diag.videos),
          adLabelSeenAt: diag.adSeen,
          iframes: [...document.querySelectorAll('iframe')].map(f => (f.getAttribute('src') || '(空)').slice(0, 150)),
          flashvarsAdKeys: fv ? Object.keys(fv).filter(k => /ad/i.test(k)) : null,
          adRollConfig: fv && fv.adRollGlobalConfig ? JSON.stringify(fv.adRollGlobalConfig).slice(0, 1500) : null,
          blocked: ui.blockedHosts,
          paused: PAUSED,
          recentEvents: events.slice(0, 20),
          userBlockStored: store.get('userBlock', null),
          storageTest: (() => { try { const t = Date.now(); GM_setValue('vh_test', t); return GM_getValue('vh_test') === t ? 'ok' : 'mismatch'; } catch (e) { return 'error: ' + e.message; } })(),
          otherHosts: [...new Set(performance.getEntriesByType('resource').map(e => {
            try { return baseOf(new URL(e.name).hostname); } catch (er) { return ''; }
          }))].filter(h => h && h !== HOME),
        };
        const text = JSON.stringify(report, null, 1);
        navigator.clipboard.writeText(text).then(
          () => alert('✅ 診斷資料已複製,直接貼給開發者就好'),
          () => prompt('請複製下面的內容:', text));
    };
    try { GM_registerMenuCommand('🔧 診斷(複製給開發者)', ui.actions.diagnose); } catch (e) {}
  }

  // 要早啟動的(安全防護)
  for (const name of active) if (MODULES[name].early) MODULES[name].run();

  // 網頁載入完成後啟動其他功能(只在主頁面)
  function startLate() {
    if (!IS_TOP) return;
    performance.setResourceTimingBufferSize(1000);
    performance.addEventListener('resourcetimingbufferfull', () => performance.clearResourceTimings());
    for (const name of active) {
      if (MODULES[name].early) continue;
      try { MODULES[name].run(); } catch (e) { console.warn('[Video Helper]', name, e); }
    }
  }

  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', startLate);
  else startLate();
})();