Greasy Fork is available in English.

在侧边显示 Bilibili 视频字幕/文稿

默认自动显示Bilibili视频字幕/文稿,支持点击跳转、文本选中、自动滚动。

// ==UserScript==
// @name         在侧边显示 Bilibili 视频字幕/文稿
// @name:en      Show transcript of Bilibili video on the side
// @version      1.1.2
// @description:en  Automatically display Bilibili video subtitles/scripts by default, support click to jump, text selection, auto-scrolling.
// @description     默认自动显示Bilibili视频字幕/文稿,支持点击跳转、文本选中、自动滚动。
// @namespace    https://bilibili.com/
// @match        https://www.bilibili.com/video/*
// @icon         https://www.bilibili.com/favicon.ico
// @author       bowencool
// @license      MIT
// @homepageURL  https://greasyfork.org/scripts/482165
// @supportURL   https://github.com/bowencool/Tampermonkey-Scripts/issues
// @grant        GM_addStyle
// ==/UserScript==

function waitForElementToExist(selector) {
  return new Promise((resolve) => {
    if (document.querySelector(selector)) {
      return resolve(document.querySelector(selector));
    }

    const observer = new MutationObserver(() => {
      if (document.querySelector(selector)) {
        resolve(document.querySelector(selector));
        observer.disconnect();
      }
    });

    observer.observe(document.body, {
      subtree: true,
      childList: true,
    });
  });
}

async function request(url, options) {
  return fetch(`https://api.bilibili.com${url}`, {
    ...options,
    credentials: "include",
  })
    .then((res) => res.json())
    .then((data) => {
      if (data.code != 0) {
        throw new Error(data.message);
      }
      return data.data;
    });
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

GM_addStyle(`
.transcript-box {
  border: 1px solid #e1e1e1;
  border-radius: 6px;
  padding: 12px 16px;
  max-height: 50vh;
  overflow: scroll;
  margin-bottom: 20px;
  pointer-events: initial;
}
.transcript-line {
    display: flex;
}
.transcript-line:hover {
  background-color: #0002;
}
.transcript-line.active {
  font-weight: bold;
  background-color: #0002;
}

.transcript-line-time {
    flex: none;
    overflow: hidden;
    width:66px;
    user-select: none;
    corsur: pointer;
    color: var(--bpx-fn-hover-color,#00b5e5);
}

.transcript-line-content {
    // white-space: nowrap;
}

`);

const MUSIC_FILTER_RATE = 0.85;

function fixNumber(n) {
  return n.toLocaleString("en-US", {
    minimumIntegerDigits: 2,
    useGrouping: false,
  });
}

function parseTime(t) {
  t = parseInt(t);
  return `${fixNumber(parseInt(t / 60))}:${fixNumber(t % 60)}`;
}

const transcriptBox = document.createElement("div");
transcriptBox.className = "transcript-box";
async function showTranscript(subtitleInfo) {
  console.log("showTranscript", subtitleInfo);
  const { body: lines } = await fetch(
    subtitleInfo.subtitle_url.replace(/^\/\//, "https://")
  ).then((res) => res.json());
  console.log("lines", lines);
  transcriptBox.innerHTML = "";
  for (let line of lines) {
    if (line.music && line.music > MUSIC_FILTER_RATE) {
      continue;
    }
    let timeLink = document.createElement("a");
    timeLink.className = "transcript-line-time";
    // timeLink.setAttribute("data-index", line.index);
    timeLink.textContent = parseTime(line.from);
    timeLink.addEventListener("click", () => {
      document.querySelector("video").currentTime = line.from;
    });
    let lineDiv = document.createElement("div");
    lineDiv.className = "transcript-line";
    lineDiv.setAttribute("data-from", line.from);
    lineDiv.setAttribute("data-to", line.to);
    lineDiv.appendChild(timeLink);
    let span = document.createElement("span");
    span.className = "transcript-line-content";
    span.textContent = line.content;

    lineDiv.appendChild(span);
    transcriptBox.appendChild(lineDiv);
  }
}

function getBvid(route /* : string|undefined */) {
  let url;
  if (route) {
    url = new URL(window.location.origin + route);
  } else {
    url = new URL(window.location.href);
  }
  const bvid = url.pathname.match(/\/video\/(\w+)/)?.[1];
  // if (!bvid) throw new Error("没有找到 bvid");
  let curPage = url.searchParams.get("p") - 1;
  if (!curPage || curPage == -1) {
    curPage = 0;
  }
  return { bvid, curPage };
}
async function getTranscript(route /* : string|undefined */) {
  const { bvid, curPage } = getBvid(route);
  if (!bvid) throw new Error("没有找到 bvid");
  const videoInfo = await request("/x/web-interface/view?bvid=" + bvid);
  const {
    subtitle: { subtitles = [] },
  } = await request(
    `/x/player/v2?aid=${videoInfo.aid}&cid=${videoInfo.pages[curPage].cid}`
  );
  console.log("subtitles", subtitles);
  transcriptBox.innerHTML = "没有字幕";
  if (subtitles.length == 0) throw new Error("没有字幕");
  return subtitles;
}

async function main() {
  "use strict";
  const subtitles = await getTranscript();

  // B站页面是SSR的,如果插入过早,页面 js 检测到实际 Dom 和期望 Dom 不一致,会导致重新渲染
  await waitForElementToExist("img.bili-avatar-img");
  const video = await waitForElementToExist("video");
  // const oldfanfollowEntry = await waitForElementToExist("#oldfanfollowEntry");
  video.addEventListener("timeupdate", () => {
    const currentTime = video.currentTime;
    const lastActiveLine = document.querySelector(".transcript-line.active");
    const lineBoxes = lastActiveLine
      ? [lastActiveLine, lastActiveLine.nextSibling]
      : document.querySelectorAll(".transcript-line");

    for (let i = 0; i < lineBoxes.length; i++) {
      const currentLine = lineBoxes[i];
      const from = +currentLine.getAttribute("data-from");
      const to = +currentLine.getAttribute("data-to");
      // console.log({ i, from, to, currentTime }, currentLine);
      if (currentTime >= to || currentTime <= from) {
        // Remove the 'active' class
        if (currentLine.classList.contains("active")) {
          currentLine.classList.remove("active");
        }
      }
      if (currentTime > from && currentTime < to) {
        const targetPosition =
          currentLine.offsetTop - transcriptBox.clientHeight * 0.5;
        transcriptBox.scrollTo(0, targetPosition);
        // Add the 'active' class to the current line
        currentLine.classList.add("active");
        break;
      }
    }
  });
  await showTranscript(subtitles[0]);
  const danmukuBox = await waitForElementToExist("#danmukuBox");
  // B站页面是SSR的,如果插入过早,页面 js 检测到实际 Dom 和期望 Dom 不一致,会导致重新渲染
  danmukuBox.parentNode.insertBefore(transcriptBox, danmukuBox);
}

async function updateTranscript(route /* : string|undefined */) {
  const subtitles = await getTranscript(route);
  await showTranscript(subtitles[0]);
}

main();

function getCurrentState(route) {
  const { bvid, curPage } = getBvid(route);
  return `${bvid}?p=${curPage}`;
}
let lastState = getCurrentState();
traceRoute();

function traceRoute() {
  // popstate 可以监测到 hashchange
  window.addEventListener("popstate", (evt) => {
    const to = getCurrentState();
    if (to !== lastState) {
      console.log("bvid changed when popstate", lastState, to);
      updateTranscript();
    }
  });
  let theHistory /* History */ = history || window.history;
  if (!theHistory) return;

  const replacement = (originFn /* History['pushState'] */) => {
    return (data /* any */, t /* string */, route /* string | undefined */) => {
      const from = getCurrentState();
      const to = getCurrentState(route);
      if (route && from !== to) {
        console.log("bvid changed when pushState", from, to, route);
        updateTranscript(route);
      }
      const ret = originFn.call(theHistory, data, t, route);
      if (to) {
        lastState = to;
      }
      return ret;
    };
  };
  overrideMethod(
    /* <History['pushState']> */ theHistory,
    "pushState",
    replacement
  );
  overrideMethod(
    /* <History['replaceState']> */ theHistory,
    "replaceState",
    replacement
  );
}
function overrideMethod /* <F extends Function> */(
  target /* : { [key: string]: any } */,
  key /* : string */,
  replacement /* : (f: F) => F */
) {
  if (!(key in target)) return;
  const originFn /* : F */ = target[key];
  const wrapped /* : F */ = replacement(originFn);
  if (wrapped instanceof Function) {
    target[key] = wrapped;
  }
}