您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在B站播放器中集成字幕列表,支持CC字幕和AI字幕,提供搜索、同步高亮、下载导出等功能
// ==UserScript== // @name Bilibili字幕实时显示插件 // @name:en Bilibili Subtitle Extractor // @namespace http://tampermonkey.net/ // @version 1.6 // @description 在B站播放器中集成字幕列表,支持CC字幕和AI字幕,提供搜索、同步高亮、下载导出等功能 // @description:en Integrate subtitle list in Bilibili video player, supports CC and AI subtitles with search, sync highlight, download and export features // @author Haleclipse & Zane // @match *://*.bilibili.com/video/* // @match *://*.bilibili.com/cheese/* // @grant none // @license MIT // ==/UserScript== (function () { "use strict"; // 防止重复执行 if (window.bilibiliSubtitleExtractorLoaded) { console.log("Bilibili字幕插件已加载,跳过重复执行"); return; } window.bilibiliSubtitleExtractorLoaded = true; // AI字幕拦截器 - 必须在页面加载前初始化 let aiSubtitleData = null; let aiSubtitleUrl = null; let aiSubtitleUpdateCallbacks = []; // 全局变量声明 let subtitles = null; let isMergedView = false; let isExpanded = false; // 注册AI字幕更新回调 function onAISubtitleUpdate(callback) { aiSubtitleUpdateCallbacks.push(callback); } // 触发AI字幕更新事件 function triggerAISubtitleUpdate(data) { aiSubtitleUpdateCallbacks.forEach((callback) => { try { callback(data); } catch (e) { console.error("AI subtitle callback error:", e); } }); } // 立即初始化拦截器 (function initAISubtitleInterceptor() { // 拦截fetch请求 const originalFetch = window.fetch; window.fetch = async (...args) => { const [url, options] = args; // 检查是否是AI字幕请求 if (typeof url === "string" && url.includes("ai_subtitle")) { console.log("Intercepted AI subtitle request:", url); aiSubtitleUrl = url; try { const response = await originalFetch(...args); const clonedResponse = response.clone(); const data = await clonedResponse.json(); console.log("AI subtitle data:", data); aiSubtitleData = data; // 触发更新事件,通知UI刷新 triggerAISubtitleUpdate(data); return response; } catch (e) { console.error("Failed to intercept AI subtitle:", e); return originalFetch(...args); } } return originalFetch(...args); }; // 拦截XMLHttpRequest const originalXHROpen = XMLHttpRequest.prototype.open; const originalXHRSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url, ...args) { this._url = url; return originalXHROpen.call(this, method, url, ...args); }; XMLHttpRequest.prototype.send = function (...args) { if (this._url && this._url.includes("ai_subtitle")) { console.log("Intercepted AI subtitle XHR:", this._url); aiSubtitleUrl = this._url; this.addEventListener("load", function () { if (this.status === 200) { try { const data = JSON.parse(this.responseText); console.log("AI subtitle XHR data:", data); aiSubtitleData = data; // 触发更新事件,通知UI刷新 triggerAISubtitleUpdate(data); } catch (e) { console.error("Failed to parse AI subtitle XHR:", e); } } }); } return originalXHRSend.call(this, ...args); }; })(); // 字幕获取模块 const SubtitleFetcher = { // 获取AI字幕数据 async getAISubtitleData() { if (aiSubtitleData) { return aiSubtitleData; } if (aiSubtitleUrl) { try { const response = await fetch(aiSubtitleUrl); const data = await response.json(); return data; } catch (e) { console.error("Failed to fetch AI subtitle:", e); return null; } } return null; }, // 获取视频信息 async getVideoInfo() { console.log("Getting video info..."); const info = { aid: window.aid || window.__INITIAL_STATE__?.aid, bvid: window.bvid || window.__INITIAL_STATE__?.bvid, cid: window.cid, }; if (!info.cid) { const state = window.__INITIAL_STATE__; info.cid = state?.videoData?.cid || state?.epInfo?.cid; } if (!info.cid && window.player) { try { const playerInfo = window.player.getVideoInfo(); info.cid = playerInfo.cid; info.aid = playerInfo.aid; info.bvid = playerInfo.bvid; } catch (e) { console.log("Failed to get info from player:", e); } } console.log("Video info:", info); return info; }, // 获取字幕配置 async getSubtitleConfig(info) { console.log("Getting subtitle config..."); // 首先尝试获取AI字幕数据 const aiSubtitleData = await this.getAISubtitleData(); if (aiSubtitleData) { console.log("Found AI subtitle data, converting format..."); return this.convertAISubtitleFormat(aiSubtitleData); } const apis = [ `//api.bilibili.com/x/player/v2?cid=${info.cid}&bvid=${info.bvid}`, `//api.bilibili.com/x/v2/dm/view?aid=${info.aid}&oid=${info.cid}&type=1`, `//api.bilibili.com/x/player/wbi/v2?cid=${info.cid}`, ]; for (const api of apis) { try { console.log("Trying API:", api); const res = await fetch(api); const data = await res.json(); console.log("API response:", data); if (data.code === 0 && data.data?.subtitle?.subtitles?.length > 0) { return data.data.subtitle; } } catch (e) { console.log("API failed:", e); } } return null; }, // 转换AI字幕格式 convertAISubtitleFormat(aiData) { console.log("Converting AI subtitle format:", aiData); // AI字幕数据格式转换为CC字幕格式 return { subtitles: [ { subtitle_url: "ai_subtitle_data", // 标记为AI字幕 lan: "ai", lan_doc: "AI生成字幕", }, ], ai_data: aiData, // 保存原始AI数据 }; }, // 获取字幕内容 async getSubtitleContent(subtitleUrl, subtitleConfig = null) { console.log("Getting subtitle content from:", subtitleUrl); // 处理AI字幕数据 if (subtitleUrl === "ai_subtitle_data" && subtitleConfig?.ai_data) { console.log("Processing AI subtitle data..."); return this.processAISubtitleData(subtitleConfig.ai_data); } try { const url = subtitleUrl.replace(/^http:/, "https:"); console.log("Using HTTPS URL:", url); const res = await fetch(url); const data = await res.json(); console.log("Subtitle content:", data); return data; } catch (e) { console.error("Failed to get subtitle content:", e); return null; } }, // 处理AI字幕数据格式 processAISubtitleData(aiData) { console.log("Processing AI subtitle data:", aiData); // 根据AI字幕的实际数据结构进行转换 // 这里需要根据实际的AI字幕JSON格式进行适配 let body = []; if (aiData.body && Array.isArray(aiData.body)) { // 如果AI字幕已经是标准格式 body = aiData.body; } else if (aiData.data && Array.isArray(aiData.data)) { // 如果数据在data字段中 body = aiData.data.map((item) => ({ from: item.from || item.start || 0, to: item.to || item.end || 0, content: item.content || item.text || "", })); } else if (Array.isArray(aiData)) { // 如果直接是数组 body = aiData.map((item) => ({ from: item.from || item.start || 0, to: item.to || item.end || 0, content: item.content || item.text || "", })); } else { console.warn("Unknown AI subtitle format:", aiData); return null; } return { body: body, font_size: 0.4, font_color: "#FFFFFF", background_alpha: 0.5, background_color: "#9C27B0", Stroke: "none", }; }, }; // 时间格式化模块 const TimeFormatter = { formatTime(seconds) { const mm = String(Math.floor(seconds / 60)).padStart(2, "0"); const ss = String(Math.floor(seconds % 60)).padStart(2, "0"); return `${mm}:${ss}`; }, // 如果需要其他格式的时间显示,可以添加更多方法 formatTimeWithMs(seconds) { const date = new Date(seconds * 1000); const mm = String(Math.floor(seconds / 60)).padStart(2, "0"); const ss = String(Math.floor(seconds % 60)).padStart(2, "0"); const ms = String(date.getMilliseconds()).slice(0, 3).padStart(3, "0"); return `${mm}:${ss},${ms}`; }, }; // UI渲染模块更新 const SubtitleUI = { injectStyles() { // 检查是否已经注入过样式 if (document.querySelector("style[data-subtitle-styles]")) { return; } const style = document.createElement("style"); style.setAttribute("data-subtitle-styles", "true"); style.textContent = ` .subtitle-container { position: relative; width: 100%; background: #ffffff; border-radius: 0 0 8px 8px; overflow: hidden; padding: 10px 0; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); transition: all 0.3s; } .subtitle-header { display: flex; height: 36px; align-items: center; padding: 0 12px; cursor: pointer; user-select: none; font-size: 14px; font-weight: 500; color: #18191c; } .arrow-icon { display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; margin-right: 6px; transform: rotate(-90deg); transition: transform 0.3s; } .arrow-icon.expanded { transform: rotate(0); } .subtitle-content { height: 0; overflow: hidden; transition: height 0.3s; } .subtitle-function { height: 36px; display: flex; align-items: center; border-bottom: 1px solid #f1f2f3; color: #61666d; font-size: 12px; background: #f6f7f8; min-width: 0; position: relative; } .subtitle-function-left { display: flex; align-items: center; flex: 1; min-width: 0; } .subtitle-function-btn { height: 100%; display: flex; align-items: center; cursor: pointer; flex: 1; justify-content: center; min-width: 0; padding: 0 8px; } .subtitle-function-btn:first-child { flex: 0 0 60px; justify-content: flex-start; padding-left: 12px; } .subtitle-function-btn:last-child { flex: 1; justify-content: flex-start; padding-left: 8px; } .subtitle-function-btn:hover { color: #00a1d6; } .subtitle-wrap { height: 393px; overflow-y: auto; padding: 10px 0; } .subtitle-item { display: flex; padding: 6px 12px; line-height: 1.5; cursor: pointer; border-bottom: 1px solid transparent; transition: all 0.2s; } .subtitle-item:hover { background: rgba(0, 161, 214, 0.1); } .subtitle-item.active { background: rgba(0, 161, 214, 0.1); border-left: 2px solid #00a1d6; } .subtitle-time { color: #61666d; margin-right: 8px; font-size: 12px; min-width: 40px; } .subtitle-text { color: #18191c; font-size: 13px; flex: 1; word-break: break-word; } .subtitle-menu { position: relative; } .subtitle-menu-dropdown { position: fixed; background: #fff; border: 1px solid #e3e5e7; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); min-width: 120px; opacity: 0; visibility: hidden; transform: translateY(-8px); transition: all 0.2s ease; z-index: 10000; } .subtitle-menu-dropdown.show { opacity: 1; visibility: visible; transform: translateY(0); } .subtitle-menu-item { padding: 8px 12px; font-size: 12px; color: #212121; cursor: pointer; transition: all 0.3s; } .subtitle-menu-item:hover { background: #f6f7f8; color: #00a1d6; } .subtitle-menu-item.active { color: #00a1d6; } /* 合并视图样式 */ .subtitle-merged { padding: 10px 12px; line-height: 1.8; color: #18191c; font-size: 14px; } .subtitle-span { margin: 0 1px; padding: 0 1px; border-radius: 2px; cursor: pointer; } .subtitle-span:hover { background: rgba(0, 161, 214, 0.1); } .subtitle-span.active { background: rgba(0, 161, 214, 0.2); color: #00a1d6; } .subtitle-function-right { display: flex; align-items: center; } .subtitle-menu-icon { width: 24px; height: 24px; padding: 4px; cursor: pointer; border-radius: 4px; display: flex; align-items: center; justify-content: center; transition: background-color 0.3s; } .subtitle-menu-icon:hover { background: rgba(0, 161, 214, 0.1); } /* 搜索功能样式 */ .subtitle-search { display: flex; align-items: center; flex: 0 1 auto; margin: 0 8px; min-width: 120px; max-width: 200px; position: relative; } .subtitle-search-input { width: 100%; height: 24px; border: 1px solid #e5e9ef; border-radius: 12px; padding: 0 8px; font-size: 12px; color: #212121; outline: none; transition: border-color 0.3s; } .subtitle-search-input:focus { border-color: #00a1d6; } .subtitle-search-btn { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); cursor: pointer; color: #757575; display: flex; align-items: center; justify-content: center; padding: 2px; } .subtitle-search-btn:hover { color: #00a1d6; } .subtitle-search-nav { display: flex; align-items: center; margin-left: 4px; white-space: nowrap; } .subtitle-search-count { font-size: 12px; color: #757575; margin: 0 4px; min-width: 36px; text-align: center; } .subtitle-search-prev, .subtitle-search-next { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 50%; color: #757575; margin: 0 2px; } .subtitle-search-prev:hover, .subtitle-search-next:hover { background: rgba(0, 161, 214, 0.1); color: #00a1d6; } .subtitle-search-prev:disabled, .subtitle-search-next:disabled { opacity: 0.5; cursor: not-allowed; } .subtitle-text mark { background-color: rgba(255, 247, 99, 0.7); color: #111; padding: 0; border-radius: 2px; } .subtitle-text mark.current { background-color: #ff9c00; color: #fff; } .subtitle-span mark { background-color: rgba(255, 247, 99, 0.7); color: #111; padding: 0; border-radius: 2px; } .subtitle-span mark.current { background-color: #ff9c00; color: #fff; } /* 响应式布局 */ @media (max-width: 400px) { .subtitle-function-btn span { font-size: 11px; } .subtitle-search { min-width: 100px; max-width: 150px; margin: 0 4px; } .subtitle-function-btn:first-child { flex: 0 0 50px; padding-left: 8px; } } @media (max-width: 320px) { .subtitle-function-btn span { font-size: 10px; } .subtitle-search { min-width: 80px; max-width: 120px; } .subtitle-function-btn:first-child { flex: 0 0 40px; } } `; document.head.appendChild(style); }, isElementScrollable(element) { return element.scrollHeight > element.clientHeight; }, createSubtitleUI() { const container = document.createElement("div"); container.className = "subtitle-container"; // 头部 const header = document.createElement("div"); header.className = "subtitle-header"; header.innerHTML = ` <div class="arrow-icon"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> <path d="m9.188 7.999-3.359 3.359a.75.75 0 1 0 1.061 1.061l3.889-3.889a.75.75 0 0 0 0-1.061L6.89 3.58a.75.75 0 1 0-1.061 1.061l3.359 3.358z"/> </svg> </div> <span>字幕列表</span> `; // 内容区 const content = document.createElement("div"); content.className = "subtitle-content"; const function_bar = document.createElement("div"); function_bar.className = "subtitle-function"; function_bar.innerHTML = ` <div class="subtitle-function-left"> <div class="subtitle-function-btn"> <span>时间</span> </div> <div class="subtitle-function-btn"> <span>字幕内容</span> </div> </div> <div class="subtitle-search"> <input type="text" class="subtitle-search-input" placeholder="搜索字幕..." /> <div class="subtitle-search-nav"> <span class="subtitle-search-count">0/0</span> <div class="subtitle-search-prev"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <polyline points="15 18 9 12 15 6"></polyline> </svg> </div> <div class="subtitle-search-next"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <polyline points="9 18 15 12 9 6"></polyline> </svg> </div> </div> </div> <div class="subtitle-function-right"> <div class="subtitle-menu"> <div class="subtitle-menu-icon"> <svg width="16" height="16" viewBox="0 0 16 16"> <circle cx="8" cy="3" r="1.5"/> <circle cx="8" cy="8" r="1.5"/> <circle cx="8" cy="13" r="1.5"/> </svg> </div> <div class="subtitle-menu-dropdown"> <div class="subtitle-menu-item" data-action="toggle-view">合并显示</div> <div class="subtitle-menu-item" data-action="copy-text">复制纯文本</div> <div class="subtitle-menu-item" data-action="copy-srt">复制SRT格式</div> <div class="subtitle-menu-item" data-action="download-text">下载纯文本</div> <div class="subtitle-menu-item" data-action="download-srt">下载SRT字幕</div> </div> </div> </div> `; const wrap = document.createElement("div"); wrap.className = "subtitle-wrap"; // 更新滚动事件监听 wrap.addEventListener("scroll", () => { SubtitleSync.ScrollManager.onManualScroll(); }); content.appendChild(function_bar); content.appendChild(wrap); container.appendChild(header); container.appendChild(content); return { container, header, content: wrap }; }, }; // 字幕同步模块更新 const SubtitleSync = { isVideoPlaying: true, // 新增统一的滚动管理器 ScrollManager: { state: { isManualScrolling: false, scrollTimeout: null, lastActiveIndex: -1, }, onManualScroll() { this.state.isManualScrolling = true; clearTimeout(this.state.scrollTimeout); this.state.scrollTimeout = setTimeout(() => { this.state.isManualScrolling = false; }, 3000); }, shouldAutoScroll() { return !this.state.isManualScrolling; }, updateActiveIndex(index) { if (this.state.lastActiveIndex !== index) { this.state.lastActiveIndex = index; return true; // 表示需要滚动 } return false; }, }, displaySubtitles(subtitles, container, isMergedView = false) { if (isMergedView) { this.displayMergedSubtitles(subtitles, container); return; } const subtitleHtml = subtitles.body .map( (item, index) => ` <div class="subtitle-item" data-index="${index}"> <span class="subtitle-time">${TimeFormatter.formatTime(item.from)}</span> <span class="subtitle-text">${item.content}</span> </div> `, ) .join(""); container.innerHTML = subtitleHtml; // 添加点击事件 container.querySelectorAll(".subtitle-item").forEach((item) => { item.addEventListener("click", () => { const index = parseInt(item.dataset.index); const subtitle = subtitles.body[index]; if (window.player && subtitle) { window.player.seek(subtitle.from); } }); }); // 更新滚动监听 container.addEventListener("scroll", () => { this.ScrollManager.onManualScroll(); }); // 监听视播放状态 if (window.player) { const observer = new MutationObserver(() => { const video = document.querySelector("video"); if (video) { this.isVideoPlaying = !video.paused; } }); observer.observe(document.querySelector(".bpx-player-container"), { subtree: true, attributes: true, }); } // 重新应用搜索高亮 if (SubtitleSearch.state.query) { SubtitleSearch.highlightResults(container, false); } }, // 计算元素在容器中的相对位置 getRelativePosition(element, container) { const containerRect = container.getBoundingClientRect(); const elementRect = element.getBoundingClientRect(); return { top: elementRect.top - containerRect.top, bottom: elementRect.bottom - containerRect.top, }; }, // 检查元素是否在容器的可视区域内 isElementInViewport(element, container) { const pos = this.getRelativePosition(element, container); const containerHeight = container.clientHeight; // 虑一定的缓冲区域 const buffer = 50; return pos.top >= -buffer && pos.bottom <= containerHeight + buffer; }, // 平滑滚动到指定元素 smoothScrollToElement(element, container) { const pos = this.getRelativePosition(element, container); const containerHeight = container.clientHeight; const targetScroll = container.scrollTop + pos.top - containerHeight / 2; container.scrollTo({ top: targetScroll, behavior: "smooth", }); }, // 更新highlightCurrentSubtitle方法 highlightCurrentSubtitle(subtitles, container, isMergedView = false) { const currentTime = window.player?.getCurrentTime() || 0; if (isMergedView) { // 获取所有字幕span const spans = container.querySelectorAll(".subtitle-span"); let activeSpanFound = false; spans.forEach((span) => { const from = parseFloat(span.dataset.from); const to = parseFloat(span.dataset.to); if (currentTime >= from && currentTime <= to) { span.classList.add("active"); activeSpanFound = true; const index = parseInt(span.dataset.index); if ( this.isVideoPlaying && this.ScrollManager.shouldAutoScroll() && this.ScrollManager.updateActiveIndex(index) ) { if (!this.isElementInViewport(span, container)) { this.smoothScrollToElement(span, container); } } } else { span.classList.remove("active"); } }); if (!activeSpanFound) { this.highlightNearestSubtitle(currentTime, spans); } } else { // 修复单条显示模式的高亮逻辑 container.querySelectorAll(".subtitle-item").forEach((item) => { item.classList.remove("active"); }); const currentSubtitle = subtitles.body.find( (item) => currentTime >= item.from && currentTime <= item.to, // 修复这里的bug ); if (currentSubtitle) { const index = subtitles.body.indexOf(currentSubtitle); const currentElement = container.querySelector( `.subtitle-item[data-index="${index}"]`, ); if (currentElement) { currentElement.classList.add("active"); // 使用ScrollManager控制滚动 if ( this.isVideoPlaying && this.ScrollManager.shouldAutoScroll() && this.ScrollManager.updateActiveIndex(index) ) { if (!this.isElementInViewport(currentElement, container)) { this.smoothScrollToElement(currentElement, container); } } } } } }, // 添加新方法用于显示合并视图 displayMergedSubtitles(subtitles, container) { // 创建包装器div const mergedContent = document.createElement("div"); mergedContent.className = "merged-view"; // 处理每个字幕 subtitles.body.forEach((item, index) => { // 创建字幕span const subtitleSpan = document.createElement("span"); subtitleSpan.className = "subtitle-span"; subtitleSpan.dataset.index = index; subtitleSpan.dataset.from = item.from; subtitleSpan.dataset.to = item.to; subtitleSpan.textContent = item.content; // 添加到容器 mergedContent.appendChild(subtitleSpan); // 添加分隔符(空格) if (index < subtitles.body.length - 1) { const separator = document.createElement("span"); separator.className = "subtitle-separator"; separator.textContent = " "; mergedContent.appendChild(separator); } }); // 清空并设置新内容 container.innerHTML = ""; container.appendChild(mergedContent); // 重新应用搜索高亮 if (SubtitleSearch.state.query) { SubtitleSearch.highlightResults(container, true); } }, // 添加辅助方法来处理最近字幕的高亮 highlightNearestSubtitle(currentTime, spans) { let nearestSpan = null; let minDiff = Infinity; spans.forEach((span) => { const from = parseFloat(span.dataset.from); const to = parseFloat(span.dataset.to); const diff = Math.min( Math.abs(currentTime - from), Math.abs(currentTime - to), ); if (diff < minDiff) { minDiff = diff; nearestSpan = span; } }); if (nearestSpan && minDiff < 1) { // 1秒内的最近字幕 nearestSpan.classList.add("active"); } }, }; // 添加新的复制功能模块 const SubtitleCopy = { // 生成纯文本格式 generatePlainText(subtitles) { return subtitles.body.map((item) => item.content).join("\n"); }, // 生成SRT格式 generateSRT(subtitles) { return subtitles.body .map((item, index) => { const startTime = TimeFormatter.formatTimeWithMs(item.from); const endTime = TimeFormatter.formatTimeWithMs(item.to); return `${index + 1}\n${startTime} --> ${endTime}\n${item.content}\n`; }) .join("\n"); }, // 获取视频ID getVideoId() { const url = window.location.href; let videoId = ""; // 普通视频:AV号或BV号 const avMatch = url.match(/\/av(\d+)/i); if (avMatch) { videoId = `AV${avMatch[1]}`; } if (!videoId) { const bvMatch = url.match(/\/(BV[a-zA-Z0-9]+)/); if (bvMatch) { videoId = bvMatch[1]; } } // 课程视频:ep号 if (!videoId) { const epMatch = url.match(/\/ep(\d+)/i); if (epMatch) { videoId = `ep${epMatch[1]}`; } } // 其他情况,尝试从URL参数获取 if (!videoId) { const urlParams = new URLSearchParams(window.location.search); const aid = urlParams.get("aid"); const bvid = urlParams.get("bvid"); const ep = urlParams.get("ep"); if (bvid) { videoId = bvid; } else if (aid) { videoId = `AV${aid}`; } else if (ep) { videoId = `ep${ep}`; } } return videoId; }, // 获取视频标题作为文件名 getVideoTitle() { // 尝试从多个位置获取视频标题 let title = ""; // 从页面标题获取 const pageTitle = document.title; if (pageTitle && !pageTitle.includes("哔哩哔哩")) { title = pageTitle .replace(/_哔哩哔哩_bilibili/g, "") .replace(/\s*-\s*哔哩哔哩/g, "") .trim(); } // 从初始状态获取 if (!title && window.__INITIAL_STATE__?.videoData?.title) { title = window.__INITIAL_STATE__.videoData.title; } // 从视频信息区域获取 if (!title) { const titleElement = document.querySelector( ".video-title, .media-title, h1[title], .video-desc .title", ); if (titleElement) { title = titleElement.textContent || titleElement.getAttribute("title") || ""; } } // 清理文件名中的非法字符 title = title.replace(/[<>:"/\\|?*]/g, "").trim(); return title || "字幕"; }, // 生成完整的文件名 generateFileName(extension) { const title = this.getVideoTitle(); const videoId = this.getVideoId(); if (videoId) { return `${title}_${videoId}_字幕.${extension}`; } else { return `${title}_字幕.${extension}`; } }, // 下载文件 downloadFile(content, filename, mimeType = "text/plain") { const blob = new Blob([content], { type: mimeType + ";charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.style.display = "none"; document.body.appendChild(link); link.click(); document.body.removeChild(link); // 清理URL对象 setTimeout(() => URL.revokeObjectURL(url), 100); // 显示下载提示 this.showDownloadTip(`已下载: ${filename}`); }, // 下载纯文本字幕 downloadPlainText(subtitles) { const content = this.generatePlainText(subtitles); const filename = this.generateFileName("txt"); this.downloadFile(content, filename, "text/plain"); }, // 下载SRT字幕 downloadSRT(subtitles) { const content = this.generateSRT(subtitles); const filename = this.generateFileName("srt"); this.downloadFile(content, filename, "text/plain"); }, // 显示下载提示 showDownloadTip(message) { const tip = document.createElement("div"); tip.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 161, 214, 0.9); color: white; padding: 12px 20px; border-radius: 6px; z-index: 10000; font-size: 14px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); `; tip.textContent = message; document.body.appendChild(tip); setTimeout(() => tip.remove(), 2000); }, // 复制到剪贴板 async copyToClipboard(text) { try { await navigator.clipboard.writeText(text); // 可以添加一个简单的提示 const tip = document.createElement("div"); tip.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.7); color: white; padding: 8px 16px; border-radius: 4px; z-index: 10000; `; tip.textContent = "复制成功"; document.body.appendChild(tip); setTimeout(() => tip.remove(), 1500); } catch (err) { console.error("复制失败:", err); } }, }; // 添加新的搜索功能模块 const SubtitleSearch = { state: { query: "", results: [], currentIndex: -1, isCaseSensitive: false, }, // 搜索字幕内容 search(subtitles, query, isCaseSensitive = false) { if (!query || !subtitles || !subtitles.body) { this.state.results = []; this.state.currentIndex = -1; return []; } this.state.query = query; this.state.isCaseSensitive = isCaseSensitive; const results = []; subtitles.body.forEach((subtitle, index) => { const content = subtitle.content; const searchText = isCaseSensitive ? content : content.toLowerCase(); const searchQuery = isCaseSensitive ? query : query.toLowerCase(); if (searchText.includes(searchQuery)) { results.push(index); } }); this.state.results = results; this.state.currentIndex = results.length > 0 ? 0 : -1; return results; }, // 高亮显示搜索结果 highlightResults(container, isMergedView = false) { if (!this.state.query || this.state.results.length === 0) { return; } const query = this.state.query; const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const pattern = new RegExp( `(${escapeRegExp(query)})`, this.state.isCaseSensitive ? "g" : "gi", ); if (isMergedView) { const spans = container.querySelectorAll(".subtitle-span"); spans.forEach((span) => { const index = parseInt(span.dataset.index); if (this.state.results.includes(index)) { span.innerHTML = span.textContent.replace( pattern, "<mark>$1</mark>", ); if (index === this.state.results[this.state.currentIndex]) { span.querySelector("mark").classList.add("current"); } } }); } else { const items = container.querySelectorAll(".subtitle-item"); items.forEach((item) => { const index = parseInt(item.dataset.index); const textSpan = item.querySelector(".subtitle-text"); if (this.state.results.includes(index)) { textSpan.innerHTML = textSpan.textContent.replace( pattern, "<mark>$1</mark>", ); if (index === this.state.results[this.state.currentIndex]) { textSpan.querySelector("mark").classList.add("current"); } } }); } }, // 导航到下一个结果 nextResult(subtitles, container, isMergedView = false) { if (this.state.results.length === 0) return; this.state.currentIndex = (this.state.currentIndex + 1) % this.state.results.length; this.navigateToResult(subtitles, container, isMergedView); }, // 导航到上一个结果 prevResult(subtitles, container, isMergedView = false) { if (this.state.results.length === 0) return; this.state.currentIndex = (this.state.currentIndex - 1 + this.state.results.length) % this.state.results.length; this.navigateToResult(subtitles, container, isMergedView); }, // 导航到当前结果 navigateToResult(subtitles, container, isMergedView = false) { if (this.state.results.length === 0 || this.state.currentIndex < 0) return; // 更新高亮 this.highlightResults(container, isMergedView); // 找到当前元素 const currentResultIndex = this.state.results[this.state.currentIndex]; let targetElement; if (isMergedView) { targetElement = container.querySelector( `.subtitle-span[data-index="${currentResultIndex}"]`, ); } else { targetElement = container.querySelector( `.subtitle-item[data-index="${currentResultIndex}"]`, ); } if (targetElement) { // 滚动到该元素 SubtitleSync.smoothScrollToElement(targetElement, container); // 更新计数显示 const countElement = document.querySelector(".subtitle-search-count"); if (countElement) { countElement.textContent = `${this.state.currentIndex + 1}/${this.state.results.length}`; } } }, // 清除搜索结果 clearResults(container, isMergedView = false) { this.state.query = ""; this.state.results = []; this.state.currentIndex = -1; // 清除高亮 if (isMergedView) { const spans = container.querySelectorAll(".subtitle-span"); spans.forEach((span) => { span.innerHTML = span.textContent; }); } else { const items = container.querySelectorAll(".subtitle-item"); items.forEach((item) => { const textSpan = item.querySelector(".subtitle-text"); textSpan.innerHTML = textSpan.textContent; }); } // 更新计数显示 const countElement = document.querySelector(".subtitle-search-count"); if (countElement) { countElement.textContent = "0/0"; } }, }; // 主函数更新 async function main() { // 检查是否已经初始化过 if (document.querySelector(".subtitle-container")) { console.log("字幕面板已存在,跳过重复创建"); return; } // 等待弹幕列表容器加载 const danmakuContainer = await new Promise((resolve) => { const check = () => { const container = document.querySelector(".bui-collapse-wrap"); if (container) { resolve(container); } else { setTimeout(check, 1000); } }; check(); }); // 再次检查(防止异步期间被其他实例创建) if (document.querySelector(".subtitle-container")) { console.log("字幕面板已存在,跳过重复创建"); return; } // 注入样式 SubtitleUI.injectStyles(); // 创建UI const { container, header, content } = SubtitleUI.createSubtitleUI(); danmakuContainer.appendChild(container); // 切换展开/收起 header.addEventListener("click", () => { isExpanded = !isExpanded; container.querySelector(".subtitle-content").style.height = isExpanded ? "429px" : "0"; // 36px(功能栏) + 393px(内容区) header .querySelector(".arrow-icon") .classList.toggle("expanded", isExpanded); }); // 显示临时提示 function showTempNotification(message) { const notification = document.createElement("div"); notification.style.cssText = ` position: fixed; top: 20px; right: 20px; background: rgba(0, 161, 214, 0.9); color: white; padding: 12px 16px; border-radius: 6px; font-size: 14px; z-index: 10000; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); transition: opacity 0.3s ease; `; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { notification.style.opacity = "0"; setTimeout(() => notification.remove(), 300); }, 2000); } // 动态字幕更新处理函数 async function handleSubtitleUpdate(aiData) { console.log("AI字幕动态加载,正在更新UI..."); // 检查UI是否存在,如果不存在说明是初次加载 const existingContainer = document.querySelector(".subtitle-container"); if (!existingContainer) { console.log("UI不存在,可能是初次加载,跳过动态更新"); return; } // 显示加载提示 showTempNotification("检测到AI字幕,正在加载..."); // 获取现有UI元素 const currentContainer = existingContainer; const currentHeader = currentContainer.querySelector(".subtitle-header"); const currentContent = currentContainer.querySelector(".subtitle-wrap"); // 检查当前展开状态 const contentElement = currentContainer.querySelector(".subtitle-content"); const currentlyExpanded = contentElement.style.height !== "0px" && contentElement.style.height !== ""; if (!currentlyExpanded) { // 如果面板未展开,先展开它以提示用户有新字幕 isExpanded = true; contentElement.style.height = "429px"; currentHeader.querySelector(".arrow-icon").classList.add("expanded"); } else { // 同步全局状态 isExpanded = true; } try { // 重新获取字幕配置和内容 const videoInfo = await SubtitleFetcher.getVideoInfo(); const subtitleConfig = await SubtitleFetcher.getSubtitleConfig(videoInfo); if (subtitleConfig) { const newSubtitles = await SubtitleFetcher.getSubtitleContent( subtitleConfig.subtitles[0].subtitle_url, subtitleConfig, ); if (newSubtitles) { // 更新全局字幕变量 subtitles = newSubtitles; // 复用现有UI,只更新内容 SubtitleSync.displaySubtitles( subtitles, currentContent, isMergedView, ); // 同步菜单状态 const toggleViewMenuItem = currentContainer.querySelector( '[data-action="toggle-view"]', ); if (toggleViewMenuItem) { toggleViewMenuItem.textContent = isMergedView ? "单条显示" : "合并显示"; toggleViewMenuItem.classList.toggle("active", isMergedView); } // 重新应用搜索高亮(如果有的话) const searchInput = currentContainer.querySelector( ".subtitle-search-input", ); if (searchInput && searchInput.value.trim()) { SubtitleSearch.highlightResults(currentContent, isMergedView); SubtitleSearch.navigateToResult( subtitles, currentContent, isMergedView, ); } console.log("AI字幕UI更新完成"); showTempNotification("AI字幕加载完成!"); } } } catch (error) { console.error("动态更新AI字幕失败:", error); showTempNotification("AI字幕加载失败"); } } // 注册AI字幕更新回调 onAISubtitleUpdate(handleSubtitleUpdate); // 初始化菜单功能(无论是否有字幕都要初始化) const menuIcon = container.querySelector(".subtitle-menu-icon"); const menuDropdown = container.querySelector(".subtitle-menu-dropdown"); // 切换菜单显示 menuIcon.addEventListener("click", (e) => { e.stopPropagation(); if (menuDropdown.classList.contains("show")) { menuDropdown.classList.remove("show"); } else { // 计算菜单位置 const rect = menuIcon.getBoundingClientRect(); menuDropdown.style.left = (rect.right - 120) + 'px'; // 右对齐,菜单宽度约120px menuDropdown.style.top = (rect.bottom + 4) + 'px'; // 距离按钮底部4px menuDropdown.classList.add("show"); } }); // 点击其他地方关闭菜单 document.addEventListener("click", () => { menuDropdown.classList.remove("show"); }); // 处理菜单项点击 menuDropdown.addEventListener("click", async (e) => { const menuItem = e.target.closest(".subtitle-menu-item"); if (!menuItem) return; const action = menuItem.dataset.action; switch (action) { case "toggle-view": isMergedView = !isMergedView; menuItem.textContent = isMergedView ? "单条显示" : "合并显示"; menuItem.classList.toggle("active", isMergedView); if (subtitles) { SubtitleSync.displaySubtitles(subtitles, content, isMergedView); // 重新应用搜索高亮 if ( container.querySelector(".subtitle-search-input").value.trim() ) { SubtitleSearch.highlightResults(content, isMergedView); SubtitleSearch.navigateToResult(subtitles, content, isMergedView); } } break; case "copy-text": if (subtitles) { await SubtitleCopy.copyToClipboard( SubtitleCopy.generatePlainText(subtitles), ); } break; case "copy-srt": if (subtitles) { await SubtitleCopy.copyToClipboard( SubtitleCopy.generateSRT(subtitles), ); } break; case "download-text": if (subtitles) { SubtitleCopy.downloadPlainText(subtitles); } break; case "download-srt": if (subtitles) { SubtitleCopy.downloadSRT(subtitles); } break; } menuDropdown.classList.remove("show"); }); // 搜索功能初始化 const searchInput = container.querySelector(".subtitle-search-input"); const prevBtn = container.querySelector(".subtitle-search-prev"); const nextBtn = container.querySelector(".subtitle-search-next"); // 搜索输入事件 searchInput.addEventListener("input", () => { const query = searchInput.value.trim(); if (query && subtitles) { const results = SubtitleSearch.search(subtitles, query); SubtitleSearch.highlightResults(content, isMergedView); // 更新计数显示 const countElement = container.querySelector(".subtitle-search-count"); countElement.textContent = results.length > 0 ? `${SubtitleSearch.state.currentIndex + 1}/${results.length}` : "0/0"; // 如果有结果,导航到第一个结果 if (results.length > 0) { SubtitleSearch.navigateToResult(subtitles, content, isMergedView); } } else { SubtitleSearch.clearResults(content, isMergedView); } }); // 上一个结果按钮 prevBtn.addEventListener("click", () => { if (subtitles) { SubtitleSearch.prevResult(subtitles, content, isMergedView); } }); // 下一个结果按钮 nextBtn.addEventListener("click", () => { if (subtitles) { SubtitleSearch.nextResult(subtitles, content, isMergedView); } }); // 加载字幕 try { const videoInfo = await SubtitleFetcher.getVideoInfo(); if (!videoInfo.cid) { throw new Error("无法获取视频信息"); } const subtitleConfig = await SubtitleFetcher.getSubtitleConfig(videoInfo); if (!subtitleConfig) { throw new Error("该视频没有CC字幕或AI字幕"); } subtitles = await SubtitleFetcher.getSubtitleContent( subtitleConfig.subtitles[0].subtitle_url, subtitleConfig, ); if (!subtitles) { throw new Error("获取字幕内容失败"); } // 显示字幕 SubtitleSync.displaySubtitles(subtitles, content, isMergedView); // 更新字幕同步逻辑 setInterval(() => { if (isExpanded) { // 移除!isMergedView条件 SubtitleSync.highlightCurrentSubtitle( subtitles, content, isMergedView, ); } }, 100); // 在合并视图中添加点击事件处理 content.addEventListener("click", (e) => { const span = e.target.closest(".subtitle-span"); if (span && window.player) { const from = parseFloat(span.dataset.from); window.player.seek(from); } }); } catch (error) { console.error("Error:", error); content.innerHTML = `<div class="subtitle-item">${error.message}</div>`; } } // 等待页面加载完成后执行 if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", main); } else { main(); } })();