B 站浏览助手

可在当前页面查看B站的字幕和封面,支持字幕下载

  1. // ==UserScript==
  2. // @name B 站浏览助手
  3. // @namespace Rhttps://www.runningcheese.com/userscripts
  4. // @description 可在当前页面查看B站的字幕和封面,支持字幕下载
  5. // @author RunningCheese
  6. // @version 1.1
  7. // @match http*://www.bilibili.com/video/*
  8. // @icon https://t1.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&size=32&url=https://www.bilibili.com
  9. // @license MIT
  10. // @grant none
  11. // ==/UserScript==
  12.  
  13. (function() {
  14. 'use strict';
  15.  
  16. // 简化的元素创建工具
  17. const elements = {
  18. createAs(nodeType, config, appendTo) {
  19. const element = document.createElement(nodeType);
  20. if (config) {
  21. Object.entries(config).forEach(([key, value]) => {
  22. element[key] = value;
  23. });
  24. }
  25. if (appendTo) appendTo.appendChild(element);
  26. return element;
  27. },
  28. getAs(selector) {
  29. return document.body.querySelector(selector);
  30. }
  31. };
  32.  
  33. // 简化的fetch函数
  34. function fetch(url, option = {}) {
  35. return new Promise((resolve, reject) => {
  36. const req = new XMLHttpRequest();
  37. req.onreadystatechange = () => {
  38. if (req.readyState === 4) {
  39. resolve({
  40. ok: req.status >= 200 && req.status <= 299,
  41. status: req.status,
  42. statusText: req.statusText,
  43. json: () => Promise.resolve(JSON.parse(req.responseText)),
  44. text: () => Promise.resolve(req.responseText)
  45. });
  46. }
  47. };
  48. if (option.credentials == 'include') req.withCredentials = true;
  49. req.onerror = reject;
  50. req.open('GET', url);
  51. req.send();
  52. });
  53. }
  54.  
  55. // 创建预览图片元素
  56. const preview = elements.createAs("img", {
  57. id: "preview",
  58. style: `
  59. position: absolute;
  60. z-index: 2000;
  61. max-width: 60vw;
  62. max-height: 60vh;
  63. border: 1px solid #fff;
  64. border-radius: 4px;
  65. box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
  66. display: none;
  67. `
  68. }, document.body);
  69.  
  70. // 创建字幕显示面板
  71. const subtitlePanel = elements.createAs("div", {
  72. id: "subtitle-panel",
  73. style: `
  74. position: fixed;
  75. top: 50%;
  76. left: 50%;
  77. transform: translate(-50%, -50%);
  78. width: 360px;
  79. max-width: 800px;
  80. max-height: 80vh;
  81. background-color: white;
  82. border-radius: 8px;
  83. box-shadow:0 4px 12px rgba(0,0,0,0.25);
  84. z-index: 10000;
  85. display: none;
  86. flex-direction: column;
  87. overflow: hidden;
  88. `
  89. }, document.body);
  90.  
  91. // 创建字幕面板标题栏
  92. const subtitleHeader = elements.createAs("div", {
  93. style: `
  94. display: flex;
  95. justify-content: space-between;
  96. align-items: center;
  97. padding: 5px 10px;
  98. background-color: #F07C99;
  99. color: white;
  100. font-weight: bold;
  101. border-top-left-radius: 8px;
  102. border-top-right-radius: 8px;
  103. cursor: move; /* 添加移动光标样式 */
  104. `
  105. }, subtitlePanel);
  106.  
  107. // 添加拖动功能
  108. let isDragging = false;
  109. let offsetX, offsetY;
  110.  
  111. // 鼠标按下事件
  112. subtitleHeader.onmousedown = function(e) {
  113. isDragging = true;
  114.  
  115. // 计算鼠标在面板内的相对位置
  116. const rect = subtitlePanel.getBoundingClientRect();
  117. offsetX = e.clientX - rect.left;
  118. offsetY = e.clientY - rect.top;
  119.  
  120. // 移除transform属性,使定位更直接
  121. subtitlePanel.style.transform = 'none';
  122.  
  123. // 更新面板位置为当前位置
  124. subtitlePanel.style.left = rect.left + 'px';
  125. subtitlePanel.style.top = rect.top + 'px';
  126.  
  127. // 防止选中文本
  128. e.preventDefault();
  129. };
  130.  
  131. // 鼠标移动事件
  132. document.addEventListener('mousemove', function(e) {
  133. if (!isDragging) return;
  134.  
  135. // 计算新位置
  136. let newLeft = e.clientX - offsetX;
  137. let newTop = e.clientY - offsetY;
  138.  
  139. // 获取面板尺寸
  140. const rect = subtitlePanel.getBoundingClientRect();
  141.  
  142. // 防止面板移出视口
  143. newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - rect.width));
  144. newTop = Math.max(0, Math.min(newTop, window.innerHeight - rect.height));
  145.  
  146. // 更新位置
  147. subtitlePanel.style.left = newLeft + 'px';
  148. subtitlePanel.style.top = newTop + 'px';
  149. });
  150.  
  151. // 鼠标释放事件
  152. document.addEventListener('mouseup', function() {
  153. isDragging = false;
  154. });
  155.  
  156. // 鼠标离开窗口事件
  157. document.addEventListener('mouseleave', function() {
  158. isDragging = false;
  159. });
  160.  
  161. // 创建字幕标题
  162. elements.createAs("div", {
  163. id: "subtitle-title",
  164. textContent: "视频字幕",
  165. style: `
  166. font-size: 14px;
  167. `
  168. }, subtitleHeader);
  169.  
  170. // ... 其余代码保持不变 ...
  171.  
  172. // 创建按钮容器
  173. const buttonContainer = elements.createAs("div", {
  174. style: `
  175. display: flex;
  176. gap: 10px;
  177. `
  178. }, subtitleHeader);
  179.  
  180. // 创建下载按钮
  181. const downloadBtn = elements.createAs("button", {
  182. textContent: "下载",
  183. style: `
  184. background-color: #fb7299;
  185. color: white;
  186. border: none;
  187. border-radius: 4px;
  188. padding: 5px 10px;
  189. cursor: pointer;
  190. font-size: 14px;
  191. `,
  192. onclick: function() {
  193. const subtitleContent = document.getElementById('subtitle-content').textContent;
  194. const blob = new Blob([subtitleContent], {type: 'text/plain;charset=utf-8'});
  195. const url = URL.createObjectURL(blob);
  196. const a = document.createElement('a');
  197. a.href = url;
  198. a.download = `bilibili_subtitle_${new Date().getTime()}.txt`;
  199. document.body.appendChild(a);
  200. a.click();
  201. document.body.removeChild(a);
  202. URL.revokeObjectURL(url);
  203. }
  204. }, buttonContainer);
  205.  
  206. // 创建关闭按钮
  207. const closeBtn = elements.createAs("button", {
  208. textContent: "关闭",
  209. style: `
  210. background-color: #fb7299;
  211. color: white;
  212. border: none;
  213. border-radius: 4px;
  214. padding: 5px 10px;
  215. cursor: pointer;
  216. font-size: 14px;
  217. `,
  218. onclick: function() {
  219. subtitlePanel.style.display = 'none';
  220. }
  221. }, buttonContainer);
  222.  
  223. // 创建字幕内容区域
  224. const subtitleContent = elements.createAs("div", {
  225. id: "subtitle-content",
  226. style: `
  227. padding: 15px;
  228. overflow-y: auto;
  229. max-height: calc(80vh - 50px);
  230. line-height: 1.6;
  231. white-space: pre-wrap;
  232. font-size: 14px;
  233. `
  234. }, subtitlePanel);
  235.  
  236. // 添加CSS样式
  237. const style = elements.createAs('style', {
  238. textContent: `
  239. .bili-icon-btn {
  240. display: inline-flex;
  241. align-items: center;
  242. justify-content: center;
  243. width: 18px;
  244. height: 18px;
  245. border-radius: 4px;
  246. cursor: pointer;
  247. margin-left: 10px;
  248. transition: background-color 0.3s;
  249. }
  250.  
  251. .bili-icon-btn svg {
  252. width: 14px;
  253. height: 14px;
  254. fill: currentColor;
  255. }
  256.  
  257. .bili-subtitle-btn {
  258. color: white;
  259. background-color: #00a1d6;
  260. }
  261.  
  262. .bili-subtitle-btn:hover {
  263. background-color: #00b5e5;
  264. color: white;
  265. }
  266.  
  267. .bili-cover-btn {
  268. color: white;
  269. background-color: #fb7299;
  270. }
  271.  
  272. .bili-cover-btn:hover {
  273. background-color: #fc8bab;
  274. color: white;
  275. }
  276.  
  277. #subtitle-panel button:hover {
  278. opacity: 0.9;
  279. }
  280. `
  281. }, document.head);
  282.  
  283. // B站字幕和封面查看器主体
  284. const bilibiliViewer = {
  285. window: "undefined" == typeof(unsafeWindow) ? window : unsafeWindow,
  286. cid: undefined,
  287. subtitle: undefined,
  288. pcid: undefined,
  289. buttonAdded: false,
  290. buttonCheckInterval: null,
  291.  
  292. toast(msg, error) {
  293. if (error) console.error(msg, error);
  294. if (!this.toastDiv) {
  295. this.toastDiv = document.createElement('div');
  296. this.toastDiv.className = 'bilibili-player-video-toast-item';
  297. }
  298. const panel = elements.getAs('.bilibili-player-video-toast-top');
  299. if (!panel) return;
  300. clearTimeout(this.removeTimmer);
  301. this.toastDiv.innerText = msg + (error ? `:${error}` : '');
  302. panel.appendChild(this.toastDiv);
  303. this.removeTimmer = setTimeout(() => {
  304. panel.contains(this.toastDiv) && panel.removeChild(this.toastDiv);
  305. }, 3000);
  306. },
  307.  
  308. getSubtitle(lan, name) {
  309. const item = this.getSubtitleInfo(lan, name);
  310. if (!item) throw('找不到所选语言字幕' + lan);
  311.  
  312. return fetch(item.subtitle_url)
  313. .then(res => res.json());
  314. },
  315.  
  316. getSubtitleInfo(lan, name) {
  317. return this.subtitle.subtitles.find(item => item.lan == lan || item.lan_doc == name);
  318. },
  319.  
  320. getInfo(name) {
  321. return this.window[name]
  322. || this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__[name]
  323. || this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__.epInfo && this.window.__INITIAL_STATE__.epInfo[name]
  324. || this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__.videoData && this.window.__INITIAL_STATE__.videoData[name];
  325. },
  326.  
  327. getEpid() {
  328. return this.getInfo('id')
  329. || /ep(\d+)/.test(location.pathname) && +RegExp.$1
  330. || /ss\d+/.test(location.pathname);
  331. },
  332.  
  333. getEpInfo() {
  334. const bvid = this.getInfo('bvid'),
  335. epid = this.getEpid(),
  336. cidMap = this.getInfo('cidMap'),
  337. page = this?.window?.__INITIAL_STATE__?.p;
  338. let ep = cidMap?.[bvid];
  339. if (ep) {
  340. this.aid = ep.aid;
  341. this.bvid = ep.bvid;
  342. this.cid = ep.cids[page];
  343. return this.cid;
  344. }
  345. ep = this.window.__NEXT_DATA__?.props?.pageProps?.dehydratedState?.queries
  346. ?.find(query => query?.queryKey?.[0] == "pgc/view/web/season")
  347. ?.state?.data;
  348. ep = (ep?.seasonInfo ?? ep)?.mediaInfo?.episodes
  349. ?.find(ep => epid == true || ep.ep_id == epid);
  350. if (ep) {
  351. this.epid = ep.ep_id;
  352. this.cid = ep.cid;
  353. this.aid = ep.aid;
  354. this.bvid = ep.bvid;
  355. return this.cid;
  356. }
  357. ep = this.window.__INITIAL_STATE__?.epInfo;
  358. if (ep) {
  359. this.epid = ep.id;
  360. this.cid = ep.cid;
  361. this.aid = ep.aid;
  362. this.bvid = ep.bvid;
  363. return this.cid;
  364. }
  365. ep = this.window.playerRaw?.getManifest();
  366. if (ep) {
  367. this.epid = ep.episodeId;
  368. this.cid = ep.cid;
  369. this.aid = ep.aid;
  370. this.bvid = ep.bvid;
  371. return this.cid;
  372. }
  373. },
  374.  
  375. async setupData() {
  376. if (this.subtitle && (this.pcid == this.getEpInfo())) return this.subtitle;
  377.  
  378. if (location.pathname == '/blackboard/html5player.html') {
  379. let match = location.search.match(/cid=(\d+)/i);
  380. if (!match) return;
  381. this.window.cid = match[1];
  382. match = location.search.match(/aid=(\d+)/i);
  383. if (match) this.window.aid = match[1];
  384. match = location.search.match(/bvid=(\d+)/i);
  385. if (match) this.window.bvid = match[1];
  386. }
  387.  
  388. this.pcid = this.getEpInfo();
  389. if ((!this.cid && !this.epid) || (!this.aid && !this.bvid)) return;
  390.  
  391. this.player = this.window.player;
  392. this.subtitle = {count: 0, subtitles: []};
  393.  
  394. return fetch(`https://api.bilibili.com/x/player${this.cid ? '/wbi' : ''}/v2?${this.cid ? `cid=${this.cid}` : `&ep_id=${this.epid}`}${this.aid ? `&aid=${this.aid}` : `&bvid=${this.bvid}`}`, {credentials: 'include'}).then(res => {
  395. if (res.status == 200) {
  396. return res.json().then(ret => {
  397. if (ret.code == -404) {
  398. return fetch(`//api.bilibili.com/x/v2/dm/view?${this.aid ? `aid=${this.aid}` : `bvid=${this.bvid}`}&oid=${this.cid}&type=1`, {credentials: 'include'}).then(res => {
  399. return res.json();
  400. }).then(ret => {
  401. if (ret.code != 0) throw('无法读取本视频APP字幕配置' + ret.message);
  402. this.subtitle = ret.data && ret.data.subtitle || {subtitles: []};
  403. this.subtitle.count = this.subtitle.subtitles.length;
  404. this.subtitle.subtitles.forEach(item => (item.subtitle_url = item.subtitle_url.replace(/https?:\/\//, '//')));
  405. return this.subtitle;
  406. });
  407. }
  408. if (ret.code != 0 || !ret.data || !ret.data.subtitle) throw('读取视频字幕配置错误:' + ret.code + ret.message);
  409. this.subtitle = ret.data.subtitle;
  410. this.subtitle.count = this.subtitle.subtitles.length;
  411. return this.subtitle;
  412. });
  413. } else {
  414. throw('请求字幕配置失败:' + res.statusText);
  415. }
  416. });
  417. },
  418.  
  419. // 获取B站视频封面URL
  420. getBiliCoverUrl() {
  421. try {
  422. // 尝试从meta标签获取封面
  423. const metaImage = document.querySelector('meta[itemprop=image]');
  424. if (metaImage) {
  425. return metaImage.content.replace(/@100w_100h_1c.png/g, '');
  426. }
  427.  
  428. // 尝试其他方法获取封面
  429. const ogImage = document.querySelector('meta[property="og:image"]');
  430. if (ogImage) {
  431. return ogImage.content.replace(/@100w_100h_1c.png/g, '');
  432. }
  433.  
  434. // 尝试从视频页面获取封面
  435. const videoInfo = this.window.__INITIAL_STATE__?.videoData;
  436. if (videoInfo && videoInfo.pic) {
  437. return videoInfo.pic;
  438. }
  439.  
  440. return null;
  441. } catch (error) {
  442. console.error('获取B站封面出错:', error);
  443. return null;
  444. }
  445. },
  446.  
  447. // 添加字幕和封面按钮到视频标题后面
  448. addButtons() {
  449. // 如果按钮已添加,则不重复添加
  450. if (elements.getAs('#subtitle-viewer-btn') && elements.getAs('#cover-viewer-btn')) {
  451. return;
  452. }
  453.  
  454. // 查找视频标题元素
  455. const titleElement = elements.getAs('.video-title') || // 普通视频页面
  456. elements.getAs('.media-title') || // 番剧页面
  457. elements.getAs('.tit') || // 其他可能的标题类
  458. elements.getAs('.bpx-player-video-title'); // 新版播放器标题
  459.  
  460. if (!titleElement) {
  461. console.log('找不到视频标题元素');
  462. return;
  463. }
  464.  
  465. // 创建封面按钮(放在前面)
  466. if (!elements.getAs('#cover-viewer-btn')) {
  467. const coverBtn = elements.createAs('a', {
  468. id: 'cover-viewer-btn',
  469. className: 'bili-icon-btn bili-cover-btn',
  470. title: '查看视频封面',
  471. innerHTML: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/><path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/></svg>',
  472. onmouseenter: (e) => this.showCoverPreview(e),
  473. onmouseleave: () => this.hideCoverPreview(),
  474. onclick: () => this.openCoverInNewTab()
  475. }, titleElement);
  476. }
  477.  
  478. // 创建字幕按钮(放在后面)
  479. if (!elements.getAs('#subtitle-viewer-btn')) {
  480. const subtitleBtn = elements.createAs('a', {
  481. id: 'subtitle-viewer-btn',
  482. className: 'bili-icon-btn bili-subtitle-btn',
  483. title: '获取视频字幕',
  484. innerHTML: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.5a1 1 0 0 0-.8.4l-1.9 2.533a1 1 0 0 1-1.6 0L5.3 12.4a1 1 0 0 0-.8-.4H2a2 2 0 0 1-2-2V2zm7.194 2.766a1.688 1.688 0 0 0-.227-.272 1.467 1.467 0 0 0-.469-.324l-.008-.004A1.785 1.785 0 0 0 5.734 4C4.776 4 4 4.746 4 5.667c0 .92.776 1.666 1.734 1.666.343 0 .662-.095.931-.26-.137.389-.39.804-.81 1.22a.405.405 0 0 0 .011.59c.173.16.447.155.614-.01 1.334-1.329 1.37-2.758.941-3.706a2.461 2.461 0 0 0-.227-.4zM11 7.073c-.136.389-.39.804-.81 1.22a.405.405 0 0 0 .012.59c.172.16.446.155.613-.01 1.334-1.329 1.37-2.758.942-3.706a2.466 2.466 0 0 0-.228-.4 1.686 1.686 0 0 0-.227-.273 1.466 1.466 0 0 0-.469-.324l-.008-.004A1.785 1.785 0 0 0 10.07 4c-.957 0-1.734.746-1.734 1.667 0 .92.777 1.666 1.734 1.666.343 0 .662-.095.931-.26z"/></svg>',
  485. onclick: () => this.showSubtitleInPanel()
  486. }, titleElement);
  487. }
  488.  
  489. this.buttonAdded = true;
  490. console.log('B站字幕和封面查看按钮已添加到标题后面');
  491. },
  492.  
  493. // 在面板中显示字幕
  494. showSubtitleInPanel() {
  495. if (!this.subtitle || this.subtitle.count === 0) {
  496. this.toast('当前视频没有可用字幕');
  497. // 创建一个临时提示面板
  498. const tempPanel = elements.createAs("div", {
  499. style: `
  500. position: fixed;
  501. top: 12%;
  502. left: 50%;
  503. transform: translate(-50%, -50%);
  504. background-color: #3F7FEA;
  505. color: white;
  506. padding: 15px 20px;
  507. border-radius: 4px;
  508. font-size: 14px;
  509. z-index: 10000;
  510. `,
  511. textContent: '当前视频没有可用字幕'
  512. }, document.body);
  513.  
  514. // 3秒后自动消失
  515. setTimeout(() => {
  516. if (document.body.contains(tempPanel)) {
  517. document.body.removeChild(tempPanel);
  518. }
  519. }, 2000);
  520. return;
  521. }
  522.  
  523. // 获取第一个可用字幕
  524. const firstSubtitle = this.subtitle.subtitles[0];
  525. if (!firstSubtitle) {
  526. this.toast('无法获取字幕信息');
  527. // 创建一个临时提示面板
  528. const tempPanel = elements.createAs("div", {
  529. style: `
  530. position: fixed;
  531. top: 12%;
  532. left: 50%;
  533. transform: translate(-50%, -50%);
  534. background-color: #3F7FEA;
  535. color: white;
  536. padding: 15px 20px;
  537. border-radius: 4px;
  538. font-size: 14px;
  539. z-index: 10000;
  540. `,
  541. textContent: '无法获取字幕信息'
  542. }, document.body);
  543.  
  544. // 3秒后自动消失
  545. setTimeout(() => {
  546. if (document.body.contains(tempPanel)) {
  547. document.body.removeChild(tempPanel);
  548. }
  549. }, 3000);
  550. return;
  551. }
  552.  
  553. // 更新标题显示字幕语言
  554. document.getElementById('subtitle-title').textContent = `视频字幕 (${firstSubtitle.lan_doc || firstSubtitle.lan})`;
  555.  
  556. // 显示加载中
  557. subtitleContent.textContent = '正在加载字幕...';
  558. subtitlePanel.style.display = 'flex';
  559.  
  560. this.getSubtitle(firstSubtitle.lan)
  561. .then(data => {
  562. if (!data || !(data.body instanceof Array)) {
  563. throw '数据错误';
  564. }
  565.  
  566. // 只提取字幕内容,不包含时间戳
  567. const formattedSubtitle = data.body.map(item => item.content).join('\r\n');
  568.  
  569. // 显示字幕内容
  570. subtitleContent.textContent = formattedSubtitle;
  571. })
  572. .catch(e => {
  573. subtitleContent.textContent = `获取字幕失败: ${e}`;
  574. this.toast('获取字幕失败', e);
  575.  
  576. // 3秒后自动关闭面板
  577. setTimeout(() => {
  578. subtitlePanel.style.display = 'none';
  579. }, 2000);
  580. });
  581. },
  582.  
  583. // 格式化时间为 mm:ss.ms 格式
  584. formatTime(seconds) {
  585. const min = Math.floor(seconds / 60);
  586. const sec = Math.floor(seconds % 60);
  587. const ms = Math.floor((seconds % 1) * 100);
  588. return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`;
  589. },
  590.  
  591. // 显示封面预览
  592. showCoverPreview(event) {
  593. const coverUrl = this.getBiliCoverUrl();
  594. if (coverUrl) {
  595. preview.src = coverUrl;
  596.  
  597. // 获取按钮位置
  598. const rect = event.currentTarget.getBoundingClientRect();
  599.  
  600. // 设置预览图片位置在按钮右下角
  601. preview.style.left = (rect.right + 10) + 'px';
  602. preview.style.top = rect.top + 'px';
  603.  
  604. // 重置任何可能的宽高限制,让图片先以原始大小加载
  605. preview.style.width = 'auto';
  606. preview.style.height = 'auto';
  607.  
  608. // 图片加载完成后检查大小
  609. preview.onload = () => {
  610. const screenWidth = window.innerWidth * 0.6;
  611. const screenHeight = window.innerHeight * 0.6;
  612.  
  613. // 如果图片尺寸超过屏幕60%,则按比例缩小
  614. if (preview.naturalWidth > screenWidth || preview.naturalHeight > screenHeight) {
  615. const widthRatio = screenWidth / preview.naturalWidth;
  616. const heightRatio = screenHeight / preview.naturalHeight;
  617. const ratio = Math.min(widthRatio, heightRatio);
  618.  
  619. preview.style.width = (preview.naturalWidth * ratio) + 'px';
  620. preview.style.height = (preview.naturalHeight * ratio) + 'px';
  621. } else {
  622. // 使用原始大小
  623. preview.style.width = preview.naturalWidth + 'px';
  624. preview.style.height = preview.naturalHeight + 'px';
  625. }
  626.  
  627. // 确保预览图片不超出视口
  628. const previewRect = preview.getBoundingClientRect();
  629.  
  630. // 检查右边界
  631. if (previewRect.right > window.innerWidth) {
  632. preview.style.left = (rect.left - previewRect.width - 10) + 'px';
  633. }
  634.  
  635. // 检查下边界
  636. if (previewRect.bottom > window.innerHeight) {
  637. preview.style.top = (window.innerHeight - previewRect.height - 10) + 'px';
  638. }
  639.  
  640. preview.style.display = 'block';
  641. };
  642. } else {
  643. console.log('未找到封面图片');
  644. }
  645. },
  646.  
  647. // 隐藏封面预览
  648. hideCoverPreview() {
  649. preview.style.display = 'none';
  650. },
  651.  
  652. // 在新标签页打开封面
  653. openCoverInNewTab() {
  654. const coverUrl = this.getBiliCoverUrl();
  655. if (coverUrl) {
  656. window.open(coverUrl, '_blank');
  657. } else {
  658. this.toast('无法获取视频封面');
  659. }
  660. },
  661.  
  662. // 重置状态,用于页面切换时
  663. reset() {
  664. this.buttonAdded = false;
  665. this.subtitle = null;
  666. this.pcid = null;
  667.  
  668. // 清除定时检查
  669. if (this.buttonCheckInterval) {
  670. clearInterval(this.buttonCheckInterval);
  671. this.buttonCheckInterval = null;
  672. }
  673. },
  674.  
  675. // 启动定时检查按钮是否存在
  676. startButtonCheck() {
  677. // 清除可能存在的旧定时器
  678. if (this.buttonCheckInterval) {
  679. clearInterval(this.buttonCheckInterval);
  680. }
  681.  
  682. // 每2秒检查一次按钮是否存在
  683. this.buttonCheckInterval = setInterval(() => {
  684. if (!elements.getAs('#subtitle-viewer-btn') || !elements.getAs('#cover-viewer-btn')) {
  685. console.log('按钮已消失,重新添加');
  686. this.buttonAdded = false;
  687. this.addButtons();
  688. }
  689. }, 2000);
  690. },
  691.  
  692. init() {
  693. this.setupData().then(subtitle => {
  694. if (!subtitle) return;
  695. this.addButtons();
  696. this.startButtonCheck(); // 启动按钮检查
  697. console.log('B站字幕和封面查看器初始化成功');
  698. }).catch(e => {
  699. console.error('B站字幕和封面查看器初始化失败', e);
  700. });
  701.  
  702. // 监听页面变化,处理SPA页面跳转
  703. let lastUrl = location.href;
  704. new MutationObserver((mutations, observer) => {
  705. // 检测URL变化,如果变化则重置状态
  706. if (lastUrl !== location.href) {
  707. lastUrl = location.href;
  708. this.reset();
  709.  
  710. // 在URL变化后重新初始化
  711. setTimeout(() => {
  712. this.setupData().then(subtitle => {
  713. if (!subtitle) return;
  714. this.addButtons();
  715. this.startButtonCheck();
  716. }).catch(e => {
  717. console.error('B站字幕和封面查看器重新初始化失败', e);
  718. });
  719. }, 1000); // 延迟1秒,等待页面加载
  720. }
  721.  
  722. // 监听DOM变化,在关键元素变化时重新添加按钮
  723. for (const mutation of mutations) {
  724. if (!mutation.target) continue;
  725. if (mutation.target.getAttribute('stage') == 1 ||
  726. mutation.target.classList.contains('bpx-player-subtitle-wrap') ||
  727. mutation.target.classList.contains('tit') ||
  728. mutation.target.classList.contains('bpx-player-ctrl-subtitle-bilingual') ||
  729. mutation.target.classList.contains('squirtle-quality-wrap') ||
  730. mutation.target.classList.contains('video-title') ||
  731. mutation.target.classList.contains('media-title')) {
  732.  
  733. // 如果按钮已添加,则不重复初始化
  734. if (!elements.getAs('#subtitle-viewer-btn') || !elements.getAs('#cover-viewer-btn')) {
  735. this.setupData().then(subtitle => {
  736. if (!subtitle) return;
  737. this.addButtons();
  738. });
  739. }
  740. break;
  741. }
  742. }
  743. }).observe(document.body, {
  744. childList: true,
  745. subtree: true,
  746. });
  747. }
  748. };
  749.  
  750. // 初始化
  751. bilibiliViewer.init();
  752. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址