Youtube subtitle panel

嗅探字幕 + 下载按钮;在 YouTube 插入字幕面板;支持点击跳转、根据播放位置自动高亮并(可选)跟随滚动到当前行;

// ==UserScript==
// @name         Youtube subtitle panel
// @namespace    https://kintong.site
// @version      1.2.2
// @description  嗅探字幕 + 下载按钮;在 YouTube 插入字幕面板;支持点击跳转、根据播放位置自动高亮并(可选)跟随滚动到当前行;
// @author       Modified by ChatGPT
// @match        *://www.youtube.com/*
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  let config = GM_getValue("subtitle_config", {
    keywords: ["subtitle", "timedtext", "aisubtitle", "srt"],
    debug: true,
    lang: "zh-CN"
  });

  /** 数据结构 **/
  /** @type {{from:number,to:number,content:string}[]} */
  let subtitles = [];
  let subtitleFound = false;

  // 只取最新一次:请求序号
  let latestSeq = 0;     // 最新被“发起”的请求序号
  let appliedSeq = 0;    // 最新已应用到 UI 的请求序号

  // 渲染/同步相关的缓存
  let panelHost = null;
  let listEl = null;
  let liItems = [];        // <li>[],与 subtitles 同序
  let startTimes = [];     // number[],与 subtitles 同序
  let endTimes = [];       // number[],与 subtitles 同序
  let activeIndex = -1;    // 当前高亮行索引

  // ------------------- 菜单配置 -------------------
  GM_registerMenuCommand("配置字幕关键字", () => {
    const input = prompt("请输入URL中的关键字,逗号分隔", config.keywords.join(","));
    if (input) {
      config.keywords = input.split(",").map(s => s.trim());
      GM_setValue("subtitle_config", config);
      alert("配置已保存: " + config.keywords.join(", "));
    }
  });

  // ------------------- DOM:字幕面板 -------------------
  function ensureSubtitlePanel() {
    if (panelHost && document.body.contains(panelHost)) return;

    const tryInsert = () => {
      const secondaryInner = document.querySelector("#secondary-inner");
      if (!secondaryInner) return false;

      panelHost = document.createElement("div");
      panelHost.id = "universal-subtitle-panel";
      panelHost.innerHTML = `
        <div class="usp-header">
          <span>📝 实时字幕</span>
          <div class="usp-actions">
            <label style="display:inline-flex;align-items:center;gap:6px;font-size:12px;opacity:.9;">
              <input id="usp-follow" type="checkbox" checked />
              跟随播放
            </label>
            <button id="usp-clear" title="清空列表">清空</button>
            <button id="usp-download" title="下载当前字幕为 SRT">下载</button>
          </div>
        </div>
        <div class="usp-list-wrap"><ol class="usp-list"></ol></div>
      `;

      const style = document.createElement("style");
      style.textContent = `
        #universal-subtitle-panel{
          box-sizing:border-box;width:100%;
          background: var(--yt-spec-general-background-a, #111);
          color: var(--yt-spec-text-primary, #fff);
          border: 1px solid rgba(255,255,255,0.08);
          border-radius: 12px; margin: 12px 0 16px 0;
          box-shadow: 0 6px 18px rgba(0,0,0,0.25); overflow: hidden;
          font-family: system-ui,-apple-system,Segoe UI,Roboto,"PingFang SC","Noto Sans CJK SC","Microsoft YaHei",sans-serif;
        }
        #universal-subtitle-panel .usp-header{
          display:flex;align-items:center;justify-content:space-between;
          padding:10px 12px;background: linear-gradient(90deg, rgba(0,123,255,.15), rgba(0,195,255,.15));
          backdrop-filter: blur(4px);font-weight:600;font-size:14px;
        }
        #universal-subtitle-panel .usp-actions button{
          margin-left:8px;font-size:12px;padding:6px 10px;border-radius:8px;
          border:1px solid rgba(255,255,255,0.2);background:transparent;color:inherit;cursor:pointer;
        }
        #universal-subtitle-panel .usp-actions button:hover{background: rgba(255,255,255,0.06);}
        #universal-subtitle-panel .usp-list-wrap{max-height: 320px; overflow:auto; scroll-behavior:smooth;}
        #universal-subtitle-panel .usp-list{list-style:none; margin:0; padding:6px 10px;}
        #universal-subtitle-panel .usp-item{
          display:flex; gap:8px; padding:8px 6px; border-bottom:1px dashed rgba(255,255,255,0.08);
          align-items:flex-start; cursor:pointer; transition: background .18s, transform .18s;
        }
        #universal-subtitle-panel .usp-item:hover{ background: rgba(0,123,255,0.08); }
        #universal-subtitle-panel .usp-item.usp-active{
          background: rgba(0,123,255,0.22);
          outline: 1px solid rgba(0,123,255,0.35);
          transform: translateZ(0);
        }
        #universal-subtitle-panel .usp-ts{
          flex:0 0 auto; font-variant-numeric: tabular-nums; opacity:.75; font-size:12px; min-width: 84px;
        }
        #universal-subtitle-panel .usp-text{ flex:1 1 auto; white-space:pre-wrap; line-height:1.35; font-size:14px; }
        #universal-subtitle-panel .usp-empty{ padding:14px; opacity:.7; font-size:13px; }
      `;
      document.documentElement.appendChild(style);

      const parent = secondaryInner.parentNode;
      if (parent) parent.insertBefore(panelHost, secondaryInner);

      listEl = panelHost.querySelector(".usp-list");
      panelHost.dataset.paused = "false";
      panelHost.dataset.follow = "true";

      panelHost.querySelector("#usp-clear").addEventListener("click", () => {
        subtitles = [];
        resetRenderState();
        renderSubtitles(true);
      });

      panelHost.querySelector("#usp-follow").addEventListener("change", (e) => {
        panelHost.dataset.follow = e.target.checked ? "true" : "false";
      });

      // 绑定下载按钮
      ensureDownloadButton();

      renderSubtitles(true);
      hookVideoTimeUpdate();
      return true;
    };

    if (!tryInsert()) {
      const mo = new MutationObserver(() => {
        if (tryInsert()) mo.disconnect();
      });
      mo.observe(document.documentElement, { childList: true, subtree: true });
    }
  }

  function resetRenderState() {
    liItems = [];
    startTimes = [];
    endTimes = [];
    activeIndex = -1;
    if (panelHost) panelHost.dataset.lastRenderedIndex = "-1";
  }

  function timeFmt(t) {
    const h = String(Math.floor(t / 3600)).padStart(2, "0");
    const m = String(Math.floor((t % 3600) / 60)).padStart(2, "0");
    const s = String(Math.floor(t % 60)).padStart(2, "0");
    const ms = String(Math.floor((t * 1000) % 1000)).padStart(3, "0");
    return `${h}:${m}:${s},${ms}`;
  }

  function appendOne(v, idx) {
    const li = document.createElement("li");
    li.className = "usp-item";
    li.dataset.index = String(idx);

    const ts = document.createElement("div");
    ts.className = "usp-ts";
    ts.textContent = `${timeFmt(v.from)} → ${timeFmt(v.to)}`;

    const text = document.createElement("div");
    text.className = "usp-text";
    text.textContent = v.content;

    li.appendChild(ts);
    li.appendChild(text);

    // 点击跳转播放
    li.addEventListener("click", () => {
      const video = document.querySelector("video");
      if (video) {
        video.currentTime = v.from + 0.01; // +0.01 避免边界条件
        video.play();
        flash(li);
      } else {
        alert("未找到视频元素!");
      }
    });

    listEl.appendChild(li);
    return li;
  }

  function flash(el) {
    el.style.transition = "background .1s";
    const old = el.style.background;
    el.style.background = "rgba(0,123,255,0.35)";
    setTimeout(() => (el.style.background = old), 160);
  }

  function renderSubtitles(fullRefresh = false) {
    if (!panelHost || !listEl) return;
    if (panelHost.dataset.paused === "true") return;

    // 统一按开始时间排序,保证时间轴正确
    subtitles.sort((a, b) => a.from - b.from);

    if (fullRefresh) {
      listEl.innerHTML = "";
      resetRenderState();
      if (subtitles.length === 0) {
        const empty = document.createElement("div");
        empty.className = "usp-empty";
        empty.textContent = "暂无字幕,等待抓取中…";
        listEl.appendChild(empty);
        listEl.parentElement.scrollTop = 0;
        return;
      }
    }

    // 从 lastRenderedIndex 之后开始增量渲染
    const lastRendered = Number(panelHost.dataset.lastRenderedIndex || "-1");
    const start = Math.max(0, lastRendered + 1);

    for (let i = start; i < subtitles.length; i++) {
      const li = appendOne(subtitles[i], i);
      liItems[i] = li;
      startTimes[i] = subtitles[i].from;
      endTimes[i] = subtitles[i].to;
    }
    panelHost.dataset.lastRenderedIndex = String(subtitles.length - 1);

    // 初次加载:滚动到顶部
    if (fullRefresh) listEl.parentElement.scrollTop = 0;
  }

  // ------------------- 根据播放时间高亮 & 跟随滚动 -------------------
  function hookVideoTimeUpdate() {
    const video = document.querySelector("video");
    if (!video) return;

    let ticking = false;
    video.addEventListener("timeupdate", () => {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(() => {
        ticking = false;
        syncToTime(video.currentTime || 0);
      });
    }, { passive: true });
  }

  function syncToTime(t) {
    if (!startTimes.length) return;

    // 二分查找:找到满足 from <= t < to 的索引
    let lo = 0, hi = startTimes.length - 1, found = -1;
    while (lo <= hi) {
      const mid = (lo + hi) >> 1;
      if (t < startTimes[mid]) {
        hi = mid - 1;
      } else if (t >= endTimes[mid]) {
        lo = mid + 1;
      } else {
        found = mid;
        break;
      }
    }

    if (found !== -1 && found !== activeIndex) {
      setActiveLine(found, /*scroll*/ panelHost?.dataset.follow === "true");
    } else if (found === -1 && activeIndex !== -1) {
      // 不在任何字幕区间,取消高亮
      clearActive();
    }
  }

  function clearActive() {
    if (activeIndex !== -1 && liItems[activeIndex]) {
      liItems[activeIndex].classList.remove("usp-active");
    }
    activeIndex = -1;
  }

  function setActiveLine(idx, scroll) {
    clearActive();
    activeIndex = idx;
    const li = liItems[idx];
    if (!li) return;
    li.classList.add("usp-active");

    if (scroll && listEl) {
      const wrap = listEl.parentElement;
      const liTop = li.offsetTop;
      const liHeight = li.offsetHeight;
      const wrapHeight = wrap.clientHeight;
      const target = Math.max(0, liTop - (wrapHeight - liHeight) / 2);
      // 仅当不在可视范围再滚动,避免频繁抖动
      if (liTop < wrap.scrollTop || (liTop + liHeight) > (wrap.scrollTop + wrapHeight)) {
        wrap.scrollTo({ top: target, behavior: "smooth" });
      }
    }
  }

  // ------------------- 解析逻辑(仅应用最新 seq 的结果) -------------------
  function tryExtract(data, url, seq) {
    // 不是最新请求,丢弃
    if (seq < appliedSeq) {
      if (config.debug) {
        console.log("%c跳过过期字幕", "background:#999;color:#fff;padding:2px 4px;border-radius:2px;", { url, seq, appliedSeq });
      }
      return;
    }

    let subs = [];

    // YouTube json3 格式
    if (data && Array.isArray(data.events)) {
      subs = data.events.flatMap(e =>
        (e.segs || []).map(s => ({
          from: e.tStartMs / 1000,
          to: (e.tStartMs + e.dDurationMs) / 1000,
          content: s.utf8
        }))
      );
    }
    // B站 body 数组
    else if (data && Array.isArray(data.body)) {
      subs = data.body.map(x => ({
        from: x.from,
        to: x.to,
        content: x.content
      }));
    }
    // 通用数组
    else if (Array.isArray(data)) {
      data.forEach(x => {
        if (x && (x.content || x.text)) subs.push({
          from: x.from || 0,
          to: x.to || 0,
          content: x.content || x.text
        });
      });
    }

    if (subs.length > 0) {
      subtitleFound = true;

      // 关键:替换而非追加,并标记已应用的最新 seq
      subtitles = subs;
      appliedSeq = seq;

      if (config.debug) {
        console.log("%c字幕来源(应用)", "background:#0066cc;color:#fff;padding:2px 4px;border-radius:2px;", { url, seq });
        console.log("%c字幕条目数", "background:#0066cc;color:#fff;padding:2px 4px;border-radius:2px;", subs.length);
      }

      ensureSubtitlePanel();
      ensureDownloadButton();

      // 重渲染并保持时间轴正确
      renderSubtitles(true);

      // 若正在播放,立刻同步一次高亮
      const video = document.querySelector("video");
      if (video && !video.paused) syncToTime(video.currentTime || 0);
    }
  }

  // ------------------- fetch/XHR 拦截(分配 seq) -------------------
  const origFetch = window.fetch;
  window.fetch = async function (...args) {
    const res = await origFetch.apply(this, args);
    const url = args[0].toString();
    if (config.keywords.some(k => url.includes(k))) {
      const seq = ++latestSeq; // 分配最新请求序号
      res.clone().json().then(d => tryExtract(d, url, seq)).catch(() => {});
    }
    return res;
  };

  const origOpen = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function (...args) {
    this._url = args[1];
    return origOpen.apply(this, args);
  };
  const origSend = XMLHttpRequest.prototype.send;
  XMLHttpRequest.prototype.send = function (...args) {
    this.addEventListener("load", function () {
      const url = this._url || "";
      if (config.keywords.some(k => url.includes(k))) {
        const seq = ++latestSeq; // 分配最新请求序号
        try {
          const d = JSON.parse(this.responseText);
          tryExtract(d, url, seq);
        } catch (e) {}
      }
    });
    return origSend.apply(this, args);
  };

  // ------------------- 下载按钮(放在清空旁) -------------------
  function ensureDownloadButton() {
    const btn = document.getElementById("usp-download");
    if (!btn) return; // 面板尚未创建
    if (btn.dataset.bound === "1") return; // 已绑定过
    btn.dataset.bound = "1";

    btn.addEventListener("click", () => {
      if (subtitles.length === 0) {
        alert("暂无可下载字幕!");
        return;
      }
      const srt = subtitles
        .map((v, i) => {
          const f = t => {
            const h = String(Math.floor(t / 3600)).padStart(2, "0");
            const m = String(Math.floor((t % 3600) / 60)).padStart(2, "0");
            const s = String(Math.floor(t % 60)).padStart(2, "0");
            const ms = String(Math.floor((t * 1000) % 1000)).padStart(3, "0");
            return `${h}:${m}:${s},${ms}`;
          };
          return `${i + 1}\n${f(v.from)} --> ${f(v.to)}\n${v.content}\n`;
        })
        .join("\n");

      const blob = new Blob([srt], { type: "text/plain;charset=utf-8" });
      const a = document.createElement("a");
      a.href = URL.createObjectURL(blob);
      a.download = "subtitles.srt";
      a.click();
      URL.revokeObjectURL(a.href);
    });
  }

  // 初始执行
  ensureSubtitlePanel();
})();