// ==UserScript== // @name Bilibili 视频时间轴 // @name:en Bilibili Video Timeline // @description 根据视频字幕, 生成视频时间轴. // @version 1.3.10 // @author Yiero // @match https://www.bilibili.com/video/* // @run-at document-start // @connect hdslb.com // @license GPL-3 // @namespace https://github.com/AliubYiero/TamperMonkeyScripts // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_setClipboard // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // ==/UserScript== /* ==UserConfig== 配置项: alwaysLoad: title: 自动加载时间轴 description: '页面载入时, 自动加载时间轴到页面中' type: checkbox default: false showInWebScreen: title: 网页全屏显示时间轴 description: 网页全屏显示将时间轴 type: checkbox default: false lockHighlightPercent: title: 高亮时间轴锁定位置 (百分比) description: 高亮时间轴锁定位置 type: number default: 30 min: 0 max: 100 copyTime: title: 自动复制时间 description: '点击时间的时候, 自动复制时间到粘贴板' type: checkbox default: false copyContent: title: 自动复制文本 description: '点击文本的时候, 自动复制文本到粘贴板' type: checkbox default: false disableSelect: title: 禁止选中文本 description: '如果勾选 [自动复制时间/文本], 对应内容将变为不可拖动选中状态. ' type: checkbox default: false ==/UserConfig== */ var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); const hookXhr = (hookUrl, callback) => { const xhrOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function() { const xhr = this; if (hookUrl(arguments[1])) { const getter = Object.getOwnPropertyDescriptor( XMLHttpRequest.prototype, "responseText" ).get; Object.defineProperty(xhr, "responseText", { get: () => { let result = getter.call(xhr); callback(result, arguments[1]); return result; } }); } return xhrOpen.apply(xhr, arguments); }; }; const removeTimelineContainer = () => { const timelineContainerList = document.querySelectorAll(".timeline-container"); timelineContainerList.forEach((timelineContainer) => timelineContainer.remove()); }; class CommandMenuManager { /** * 获取所有按钮列表 */ static get() { return this.menuCommandList; } /** * 设置按钮 */ static set(buttonList) { this.menuCommandList = buttonList; } /** * 添加按钮 */ static add(...button) { this.menuCommandList.push(...button); } /** * 移除所有按钮 */ static removeAll() { this.menuCommandList.forEach((button) => { button.remove(); }); this.menuCommandList = []; } /** * 注册所有按钮 */ static registerAll() { this.menuCommandList.forEach((button) => { button.register(); }); } /** * 按索引手动激活某个按钮 */ static click(index) { const button = this.menuCommandList[index]; if (!button) return; button.click(); } } __publicField(CommandMenuManager, "menuCommandList", []); class MenuCommand { constructor(name, callback) { __publicField(this, "menuId", 0); this.name = name; this.callback = callback; this.name = name; this.callback = callback; } /** * 注册菜单 */ register() { this.menuId = GM_registerMenuCommand(this.name, (e) => { this.callback(e, this); }); } /** * 手动激活回调函数 */ click() { return this.callback(void 0, this); } /** * 移除菜单 */ remove() { GM_unregisterMenuCommand(this.menuId); } } const getVideoSubtitleData = async (subtitle) => { const subtitleDate = await fetch(subtitle.subtitle_url).then((r) => r.json()); return subtitleDate.body; }; const timelineUI = ` Timeline UI

\u65F6\u95F4\u8F74 - \u4E2D\u6587\uFF08\u81EA\u52A8\u751F\u6210\uFF09

av113752863147248 BV1RE6oYtEaf
`; const timelineItemUi = ` Timeline Item UI
\u65F6\u95F4 \u5185\u5BB9
`; const timelineUiStyle = `/* \u4E3B\u5BB9\u5668 */ .timeline-container { height: 70vh; box-shadow: #d8d8d8 0 0 10px; margin-bottom: 24px; z-index: 999; display: flex; gap: 8px; flex-flow: column; border-radius: 4px; background-color: #ffffff; pointer-events: all; } /* \u5BBD\u5C4F\u72B6\u6001\u4E0D\u663E\u793A\u65F6\u95F4\u8F74 */ #mirror-vdcon:has(.bpx-player-container[data-screen="wide"]) .timeline-container { display: none; } /* \u7F51\u9875\u5168\u5C4F\u663E\u793A\u65F6\u95F4\u8F74 (\u9700\u6839\u636E\u7528\u6237\u914D\u7F6E) */ #mirror-vdcon:has(.bpx-player-container[data-screen="web"]):has(.timeline-container[data-show-in-web-screen="true"]) #bilibili-player { width: calc(100vw - 411px); } #mirror-vdcon:has(.bpx-player-container[data-screen="web"]) .timeline-container[data-show-in-web-screen="true"] { position: fixed; top: 0; right: 0; height: 100vh; width: 411px; z-index: 999999; } /* \u65F6\u95F4/\u6587\u672C\u662F\u5426\u53EF\u4EE5\u9009\u4E2D (\u9700\u6839\u636E\u7528\u6237\u914D\u7F6E) */ .timeline-container[data-disable-select="true"][data-copy-time="true"] .timeline-start-time, .timeline-container[data-disable-select="true"][data-copy-content="true"] .timeline-content { user-select: none; } /* \u5934\u90E8\u5BB9\u5668 */ .timeline-header { position: sticky; top: 0; display: flex; flex-flow: column; gap: 4px; justify-content: center; align-items: center; background-color: #fff; box-shadow: inherit; padding: 10px 0; } /* \u6807\u9898 */ .timeline-title { color: #333; padding: 0; margin: 0; font-size: 20px; } /* \u526F\u6807\u9898 */ .timeline-sub-title-container { display: flex; align-items: center; justify-content: space-between; width: 90%; gap: 5vw; } /* \u526F\u6807\u9898 (\u89C6\u9891\u7F16\u53F7) */ .timeline-video-id { color: #aaaaaa; font-size: 12px; display: flex; flex-flow: column; justify-content: right; align-items: flex-end; } /* \u526F\u6807\u9898 (\u65F6\u95F4\u8F74\u5C45\u4E2D\u6309\u94AE - \u5173\u95ED\u72B6\u6001) */ .timeline-sub-button-container { display: flex; gap: 4px; } .timeline-sub-button { font-size: 12px; padding: 4px 8px; outline: none; border: none; border-radius: 5px; background-color: #444; color: #ccffff; } .timeline-sub-button:hover { box-shadow: #aaa 0 0 10px; } /* \u526F\u6807\u9898 (\u65F6\u95F4\u8F74\u5C45\u4E2D\u6309\u94AE - \u5F00\u542F\u72B6\u6001) */ .timeline-sub-button.active { background-color: #ccffff; color: #444; } .timeline-active-button { display: none; } .timeline-not-active-button { display: initial; } .timeline-sub-button.active { & .timeline-active-button { display: initial; } & .timeline-not-active-button { display: none; } } /* \u8DF3\u8FC7\u7A7A\u767D \u6309\u94AE */ .timeline-jump-blank-button { position: relative; } /* \u63D0\u793A\u6846 */ .timeline-tip { opacity: 0; font-size: 12px; position: absolute; bottom: -25px; margin-top: 5px; padding: 4px 8px; border-radius: 8px; white-space: nowrap; left: 50%; transform: translateX(-50%); background-color: rgba(128, 128, 128, 0.50); color: #fff; transition: all .3s; } .timeline-jump-blank-button:hover .timeline-tip { opacity: 1; } /* \u65F6\u95F4\u8F74\u5BB9\u5668 */ .timeline-content-container { display: flex; flex-flow: column; overflow-y: auto; scrollbar-width: thin; } /* \u65F6\u95F4\u8F74\u9879 */ .timeline-item { display: flex; gap: 8px; padding: 4px 16px; border-radius: 4px; font-size: 14px; align-items: center; } /* \u6FC0\u6D3B\u7684\u65F6\u95F4\u8F74 */ .timeline-item.active { background-color: #ccffff; padding: 4px 16px; margin: 4px 0; font-size: 16px; } /* \u9AD8\u4EAE\u663E\u793A\u9F20\u6807\u6D6E\u52A8\u7684\u65F6\u95F4\u8F74 */ .timeline-item:hover { background: #ddffff; } /* \u65F6\u95F4\u8F74 (\u5F00\u59CB\u65F6\u95F4) */ .timeline-start-time { color: #aaa; font-size: 12px; width: 6em; } /* \u65F6\u95F4\u8F74 (\u6587\u672C) */ .timeline-content { flex: 1; color: #333; } `; const uiCreator = (htmlContent, cssContent) => { if (cssContent) { GM_addStyle(cssContent); } const domParser = new DOMParser(); const uiDoc = domParser.parseFromString(htmlContent, "text/html"); const documentFragment = new DocumentFragment(); const filterScriptNodeList = Array.from(uiDoc.body.children).filter((node) => node.nodeName !== "SCRIPT"); documentFragment.append(...filterScriptNodeList); return documentFragment; }; /* * @module : @yiero/gmlib * @author : Yiero * @version : 0.1.5 * @description : GM Lib for Tampermonkey * @keywords : tampermonkey, lib, scriptcat, utils * @license : MIT * @repository : git+https://github.com/AliubYiero/GmLib.git */ const isIframe = () => { return Boolean( window.frameElement && window.frameElement.tagName === "IFRAME" || window !== window.top ); }; function elementWaiter(selector, config = {}) { const { parent = document.body, timeoutPerSecond = 20, delayPerSecond = 0.5 } = config; return new Promise((resolve, reject) => { const returnElement = (selector2) => { setTimeout(() => { const element2 = parent.querySelector(selector2); if (!element2) { reject(new Error("Void Element")); return; } window.dispatchEvent(new CustomEvent("ElementUpdate", { detail: element2 })); resolve(element2); }, delayPerSecond * 1e3); }; const element = parent.querySelector(selector); if (element) { returnElement(selector); return; } if (MutationObserver) { const timer2 = timeoutPerSecond && window.setTimeout(() => { observer.disconnect(); returnElement(selector); }, timeoutPerSecond * 1e3); const observeElementCallback = (mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((addNode) => { if (addNode.nodeType !== Node.ELEMENT_NODE) { return; } const addedElement = addNode; const element2 = addedElement.matches(selector) ? addedElement : addedElement.querySelector(selector); if (element2) { timer2 && clearTimeout(timer2); returnElement(selector); } }); }); }; const observer = new MutationObserver(observeElementCallback); observer.observe(parent, { subtree: true, childList: true }); return; } const intervalDelay = 100; let intervalCounter = 0; const maxIntervalCounter = Math.ceil(timeoutPerSecond * 1e3 / intervalDelay); const timer = window.setInterval(() => { if (++intervalCounter > maxIntervalCounter) { clearInterval(timer); returnElement(selector); return; } const element2 = parent.querySelector(selector); if (element2) { clearInterval(timer); returnElement(selector); } }, intervalDelay); }); } class GMStorage { constructor(key, defaultValue) { __publicField(this, "listenerId", 0); this.key = key; this.defaultValue = defaultValue; this.key = key; this.defaultValue = defaultValue; } /** * 获取当前存储的值 */ get() { return GM_getValue(this.key, this.defaultValue); } /** * 给当前存储设置一个新值 */ set(value) { return GM_setValue(this.key, value); } /** * 添加 值 到当前储存的数组尾部. * * @warn 只能在储存值是数组时使用. */ add(appendValue) { const list = this.get(); if (!Array.isArray(list)) { return; } list.push(appendValue); this.set(list); } /** * 移除当前键 */ remove() { GM_deleteValue(this.key); } /** * 监听元素更新, 同时只能存在 1 个监听器 */ updateListener(callback) { this.removeListener(); this.listenerId = GM_addValueChangeListener(this.key, (key, oldValue, newValue, remote) => { callback({ key, oldValue, newValue, remote }); }); } /** * 移除元素更新回调 */ removeListener() { GM_removeValueChangeListener(this.listenerId); } } const CenterTimelineStorage = new GMStorage("centerTimeline", true); const JumpBlankStorage = new GMStorage("JumpBlank", false); const AlwaysLoadStorage = new GMStorage("\u914D\u7F6E\u9879.alwaysLoad", false); const CopyTimeStorage = new GMStorage("\u914D\u7F6E\u9879.copyTime", false); const CopyContentStorage = new GMStorage("\u914D\u7F6E\u9879.copyContent", false); const DisableSelectStorage = new GMStorage("\u914D\u7F6E\u9879.disableSelect", false); const ShowInWebScreen = new GMStorage("\u914D\u7F6E\u9879.showInWebScreen", false); const LockHighlightPercent = new GMStorage("\u914D\u7F6E\u9879.lockHighlightPercent", 30); const timelineUIEvent = async (timelineContainer) => { const timelineActiveButton = await elementWaiter( ".timeline-active-center-button", { parent: timelineContainer, delayPerSecond: 0 } ); const isCenterTimeline = CenterTimelineStorage.get(); isCenterTimeline && timelineActiveButton.classList.add("active"); const jumpBlankButton = await elementWaiter( ".timeline-jump-blank-button", { parent: timelineContainer, delayPerSecond: 0 } ); const isJumpBlank = JumpBlankStorage.get(); isJumpBlank && jumpBlankButton.classList.add("active"); const isCopyTime = CopyTimeStorage.get(); const isCopyContent = CopyContentStorage.get(); const videoContainer = await elementWaiter("video"); timelineContainer.addEventListener("click", (e) => { const element = e.target; if (element.closest(".timeline-active-center-button")) { timelineActiveButton.classList.toggle("active"); CenterTimelineStorage.set(!CenterTimelineStorage.get()); } if (element.closest(".timeline-jump-blank-button")) { jumpBlankButton.classList.toggle("active"); JumpBlankStorage.set(!JumpBlankStorage.get()); } const timelineItem = element.closest(".timeline-item"); if (timelineItem) { videoContainer.currentTime = Number(timelineItem.dataset.from) || 0; } if (isCopyTime && element.classList.contains("timeline-start-time")) { GM_setClipboard(element.textContent || ""); } if (isCopyContent && element.classList.contains("timeline-content")) { GM_setClipboard(element.textContent || ""); } }); }; const toTimeString = (second) => { const date = new Date(second); return [ date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds() ].map((time) => time.toString().padStart(2, "0")).join(":") + `.${Math.round(date.getUTCMilliseconds() / 10).toString().padStart(2, "0")}`; }; const parseTimelineItemHtmlContent = (subtitleData, timelineHtmlContent) => { const startTime = toTimeString(subtitleData.from * 1e3); const content = subtitleData.content; let addedTimelineItemHtmlContent = timelineHtmlContent; [["\u65F6\u95F4", startTime], ["\u5185\u5BB9", content]].forEach(([replacer, replaceValue]) => { addedTimelineItemHtmlContent = addedTimelineItemHtmlContent.replace(replacer, replaceValue); }); const datasetInfoList = []; for (let subtitleDataKey in subtitleData) { const subtitleDataValue = subtitleData[subtitleDataKey]; datasetInfoList.push(`data-${subtitleDataKey}="${subtitleDataValue}"`); } return addedTimelineItemHtmlContent.replace(new RegExp('(?<=
{ const containerDocumentFragment = uiCreator(timelineUI, timelineUiStyle); const timelineContainer = await elementWaiter( ".timeline-container", { parent: containerDocumentFragment, delayPerSecond: 0 } ); [ ["disableSelect", DisableSelectStorage.get()], ["copyTime", CopyTimeStorage.get()], ["copyContent", CopyContentStorage.get()], ["showInWebScreen", ShowInWebScreen.get()] ].forEach(([datasetKey, value]) => { timelineContainer.dataset[datasetKey] = String(value); }); const timelineContentContainer = await elementWaiter( ".timeline-content-container", { parent: timelineContainer, delayPerSecond: 0 } ); const title = await elementWaiter(".timeline-title", { parent: timelineContainer, delayPerSecond: 0 }); title.textContent = `\u65F6\u95F4\u8F74 - ${subtitleTitle}`; const videoAid = await elementWaiter(".timeline-video-aid", { parent: timelineContainer, delayPerSecond: 0 }); const { aid, bvid } = PlayerInfo.get().data; const videoBvId = await elementWaiter(".timeline-video-bvid", { parent: timelineContainer, delayPerSecond: 0 }); videoAid.textContent = `av${aid}`; videoBvId.textContent = bvid; const reduceTimeWithJumpBlank = subtitleDataList.reduce((reduceTime, item, index) => { if (index === 0) return reduceTime; const prevItem = subtitleDataList[index - 1]; reduceTime += item.from - prevItem.to; return reduceTime; }, 0); elementWaiter(".timeline-reduce-time-tip", { delayPerSecond: 0 }).then((tipElement) => { tipElement.textContent = `\u7A7A\u767D\u65F6\u95F4 ${Math.ceil(reduceTimeWithJumpBlank)} s (${toTimeString(reduceTimeWithJumpBlank * 1e3)})`; }); const itemDocumentFragment = uiCreator(timelineItemUi); const timelineItem = await elementWaiter(".timeline-item", { parent: itemDocumentFragment, delayPerSecond: 0 }); const subtitleContentList = []; for (const subtitleData of subtitleDataList) { const addedTimelineItemHtmlContent = parseTimelineItemHtmlContent(subtitleData, timelineItem.outerHTML); subtitleContentList.push(addedTimelineItemHtmlContent); } timelineContentContainer.innerHTML = subtitleContentList.join(""); const rightContainer = await elementWaiter(".right-container-inner"); const rightItemList = Array.from(document.querySelectorAll(".right-container-inner > *")); const upPanelContainer = await elementWaiter(".up-panel-container", { delayPerSecond: 1 }); const newRightItemList = [ upPanelContainer, timelineContainer, ...rightItemList.filter((item) => !item.classList.contains("up-panel-container")) ]; newRightItemList.forEach((item) => rightContainer.appendChild(item)); await timelineUIEvent(timelineContainer); return { container: timelineContainer, contentContainer: timelineContentContainer, itemList: Array.from(timelineContentContainer.querySelectorAll(".timeline-item")) }; }; function inRange(number, start, end) { const isTypeSafe = typeof number === "number" && typeof start === "number" && (typeof end === "undefined" || typeof end === "number"); if (!isTypeSafe) { return false; } if (typeof end === "undefined") { end = start; start = 0; } return number >= Math.min(start, end) && number < Math.max(start, end); } const scrollBy = (container, targetElement, scrollPercent) => { const { top: containerTop, height: containerHeight } = container.getBoundingClientRect(); const { top: targetTop } = targetElement.getBoundingClientRect(); const yOffset = targetTop - containerTop - Math.round(containerHeight * scrollPercent); container.scrollBy({ top: yOffset, behavior: "smooth" }); }; const createTimelineContainer = async (subtitle) => { const subtitleDataList = await getVideoSubtitleData(subtitle); const uiTarget = await timelineUiImporter(subtitleDataList, subtitle.lan_doc); const { contentContainer: timelineContentContainer, itemList: timelineItemList } = uiTarget; let currentIndex = 0; const lockHighlightPercent = LockHighlightPercent.get() / 100; CenterTimelineStorage.updateListener(({ newValue }) => { if (!newValue) return; scrollBy(timelineContentContainer, timelineItemList[currentIndex], lockHighlightPercent); }); elementWaiter("video").then((video) => { video.addEventListener("timeupdate", () => { const { from: startTime, to: endTime } = subtitleDataList[currentIndex]; const { from: nextStartTime = endTime, to: nextEndTime = endTime } = subtitleDataList[currentIndex + 1] || {}; let videoPlayStat = 3; const { currentTime } = video; if (inRange(currentTime, startTime, endTime)) { videoPlayStat = 0; } else if (inRange(currentTime, endTime, nextStartTime)) { videoPlayStat = 1; } else if (inRange(currentTime, nextStartTime, nextEndTime)) { videoPlayStat = 2; } if (videoPlayStat === 0) { const { classList } = timelineItemList[currentIndex]; !classList.contains("active") && classList.add("active"); return; } if (videoPlayStat === 1 && JumpBlankStorage.get()) { video.currentTime = nextStartTime; return; } if (videoPlayStat === 2) { timelineItemList[currentIndex].classList.remove("active"); timelineItemList[++currentIndex].classList.add("active"); } else { timelineItemList[currentIndex].classList.remove("active"); const currentSubtitle = subtitleDataList.find((subtitleData) => currentTime <= subtitleData.from); if (!currentSubtitle) return; currentIndex = currentSubtitle.sid - 1; timelineItemList[currentIndex].classList.add("active"); } if (CenterTimelineStorage.get()) { scrollBy(timelineContentContainer, timelineItemList[currentIndex], lockHighlightPercent); } }); }); }; const LockedTimelineMenuCommand = new MenuCommand("\u5F53\u524D\u89C6\u9891\u6CA1\u6709\u5B57\u5E55", async () => { }); class isLoading { static get stat() { return this.isLoading; } static set(stat) { this.isLoading = stat; } static toggle() { this.isLoading = !this.isLoading; } } __publicField(isLoading, "isLoading", false); const registerTimelineButton = async (playerInfo) => { if (!playerInfo) return Promise.resolve([]); const videoSubtitleList = playerInfo.data.subtitle.subtitles || []; if (!videoSubtitleList.length) { return Promise.resolve([LockedTimelineMenuCommand]); } return videoSubtitleList.map((subtitle) => { const TimeLineMenuCommand = new MenuCommand(`\u751F\u6210\u89C6\u9891\u65F6\u95F4\u8F74 - ${subtitle.lan_doc}`, async () => { if (isLoading.stat) { return; } isLoading.set(true); /* @__PURE__ */ (() => { })("\u751F\u6210\u65F6\u95F4\u8F74: ", subtitle.lan_doc); removeTimelineContainer(); await createTimelineContainer(subtitle); isLoading.set(false); }); TimeLineMenuCommand.register(); return TimeLineMenuCommand; }); }; const registerButtons = async (playerInfo) => { CommandMenuManager.removeAll(); const FreshCommandMenu = new MenuCommand("\u5237\u65B0", () => { registerButtons(PlayerInfo.get()); }); CommandMenuManager.add(FreshCommandMenu); CommandMenuManager.add(...await registerTimelineButton(playerInfo)); CommandMenuManager.registerAll(); if (AlwaysLoadStorage.get()) { elementWaiter(".video-page-card-small", { parent: document }).then(() => { const buttonList = CommandMenuManager.get(); const timelineButton = buttonList.find((button) => button.name !== "\u5237\u65B0"); if (!timelineButton) return; timelineButton.click(); }); } }; class PlayerInfo { static get() { return this.playerInfo; } static set(playerInfo) { this.playerInfo = playerInfo; } } __publicField(PlayerInfo, "playerInfo"); const handleHookBaseInfo = () => { hookXhr( (url) => { return url.startsWith("https://api.bilibili.com/x/player/wbi/v2") || url.startsWith("//api.bilibili.com/x/player/wbi/v2"); }, async (responseText) => { PlayerInfo.set(JSON.parse(responseText)); removeTimelineContainer(); await registerButtons(PlayerInfo.get()); } ); }; (async () => { if (isIframe()) { return; } handleHookBaseInfo(); })();