// ==UserScript== // @name Bilibili CC字幕批量下载复制查看器 // @namespace indefined // @updateURL https://raw.githubusercontent.com/WanderLandWalker/Bilibili-CC-Subtitle-Tool/main/Bilibili-CC-Subtitle-Tool.user.js // @downloadURL https://raw.githubusercontent.com/WanderLandWalker/Bilibili-CC-Subtitle-Tool/main/Bilibili-CC-Subtitle-Tool.user.js // @version 1.0 // @description 支持B站CC字幕单集与合集/选集批量下载、语言切换、复制查看、多格式导出、窗口拖动和悬浮按钮 // @author Wanderland Walker // @match http*://www.bilibili.com/video/* // @match http*://www.bilibili.com/bangumi/play/ss* // @match http*://www.bilibili.com/bangumi/play/ep* // @match https://www.bilibili.com/cheese/play/ss* // @match https://www.bilibili.com/cheese/play/ep* // @match http*://www.bilibili.com/list/watchlater* // @match https://www.bilibili.com/medialist/play/watchlater/* // @match http*://www.bilibili.com/medialist/play/ml* // @match http*://www.bilibili.com/blackboard/html5player.html* // @license MIT // @grant GM_setClipboard // @grant GM_addStyle // @grant GM_registerMenuCommand // ==/UserScript== (function() { 'use strict'; const elements = { subtitleStyle:` `, oldEnableIcon:` `, oldDisableIcon:` `, newDisableIcon:` `, newEnableIcon:` `, createAs(nodeType,config,appendTo){ const element = document.createElement(nodeType); config&&this.setAs(element,config); appendTo&&appendTo.appendChild(element); return element; }, setAs(element,config,appendTo){ config&&Object.entries(config).forEach(([key, value])=>{ element[key] = value; }); appendTo&&appendTo.appendChild(element); return element; }, getAs(selector,config,appendTo){ if(selector instanceof Array) { return selector.map(item=>this.getAs(item)); } const element = document.body.querySelector(selector); element&&config&&this.setAs(element,config); element&&appendTo&&appendTo.appendChild(element); return element; }, createSelector(config,appendTo){ const selector = this.createAs('div',{ className:"bilibili-player-block-string-type bpui-component bpui-selectmenu selectmenu-mode-absolute", style:"width:"+config.width },appendTo), selected = config.datas.find(item=>item.value==config.initValue), label = this.createAs('div',{ className:'bpui-selectmenu-txt', innerHTML: selected?selected.content:config.initValue },selector), arraw = this.createAs('div',{ className:'bpui-selectmenu-arrow bpui-icon bpui-icon-arrow-down' },selector), list = this.createAs('ul',{ className:'bpui-selectmenu-list bpui-selectmenu-list-left', style:`max-height:${config.height||'100px'};overflow:hidden auto;white-space:nowrap;`, onclick:e=>{ label.dataset.value = e.target.dataset.value; label.innerHTML = e.target.innerHTML; config.handler(e.target.dataset.value); } },selector); config.datas.forEach(item=>{ this.createAs('li',{ className:'bpui-selectmenu-list-row', innerHTML:item.content },list).dataset.value = item.value; }); return selector; }, createRadio(config,appendTo){ this.createAs('input',{ ...config,type: "radio",style:"cursor:pointer;5px;vertical-align: middle;" },appendTo); this.createAs('label',{ style:"margin-right: 5px;cursor:pointer;vertical-align: middle;", innerText:config.value },appendTo).setAttribute('for',config.id); } }; // ==================== 公共UI工具:拖拽与状态管理 ==================== const uiManager = { dragMap: new WeakMap(), makeDraggable(handle, container, options = {}) { const state = { isDragging: false, offsetX: 0, offsetY: 0 }; const onMouseDown = (e) => { if (e.target.closest('button, a, select, input, textarea, [data-no-drag]')) return; e.preventDefault(); e.stopPropagation(); state.isDragging = true; const rect = container.getBoundingClientRect(); state.offsetX = e.clientX - rect.left; state.offsetY = e.clientY - rect.top; handle.style.cursor = 'grabbing'; container.style.transition = 'none'; container.classList.add('dragging'); }; const onMouseMove = (e) => { if (!state.isDragging) return; let x = e.clientX - state.offsetX; let y = e.clientY - state.offsetY; const maxX = window.innerWidth - container.offsetWidth; const maxY = window.innerHeight - container.offsetHeight; x = Math.max(0, Math.min(x, maxX)); y = Math.max(0, Math.min(y, maxY)); container.style.left = x + 'px'; container.style.top = y + 'px'; container.style.transform = 'none'; if (options.onMove) options.onMove(x, y); }; const onMouseUp = () => { if (!state.isDragging) return; state.isDragging = false; handle.style.cursor = 'move'; container.style.transition = options.transition || ''; container.classList.remove('dragging'); if (options.onEnd) options.onEnd(); }; handle.addEventListener('mousedown', onMouseDown); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); this.dragMap.set(container, { destroy() { handle.removeEventListener('mousedown', onMouseDown); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }}); return this.dragMap.get(container); }, saveState(key, state) { try { localStorage.setItem(key, JSON.stringify(state)); } catch(e) {} }, loadState(key, defaults) { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : defaults; } catch(e) { return defaults; } } }; function fetch(url, option = {}) { return new Promise((resolve, reject) => { const req = new XMLHttpRequest(); req.onreadystatechange = ()=> { if (req.readyState === 4) { resolve({ ok: req.status>=200&&req.status<=299, status: req.status, statusText: req.statusText, body: req.response, json: ()=>Promise.resolve(JSON.parse(req.responseText)), text: ()=>Promise.resolve(req.responseText) }); } }; if (option.credentials == 'include') req.withCredentials = true; req.onerror = reject; req.open('GET', url); req.send(); }); } //编码器,用于将B站BCC字幕编码为常见字幕格式下载 const encoder = { assHead : [ '[Script Info]', `Title: ${document.title}`, 'ScriptType: v4.00+', 'Collisions: Reverse', 'PlayResX: 1280', 'PlayResY: 720', 'WrapStyle: 3', 'ScaledBorderAndShadow: yes', '; ----------------------', '; 本字幕由CC字幕助手自动转换', `; 字幕来源${document.location}`, '; 脚本地址https://greasyfork.org/scripts/378513', '; 设置了字幕过长自动换行,但若字幕中没有空格换行将无效', '; 字体大小依据720p 48号字体等比缩放', '; 如显示不正常请尝试使用SRT格式', '','[V4+ Styles]', 'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, ' +'BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, ' +'BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding', 'Style: Default,Segoe UI,48,&H00FFFFFF,&HF0000000,&H00000000,&HF0000000,1,0,0,0,100,100,0,0.00,1,1,3,2,30,30,20,1', '','[Events]', 'Format: Layer, Start, End, Style, Actor, MarginL, MarginR, MarginV, Effect, Text' ], // ==================== 新增:拖动相关变量 ==================== isDragging: false, dragOffsetX: 0, dragOffsetY: 0, dialogElement: null, panelElement: null, currentLan: null, languageSelect: null, languageStatus: null, formatSelect: null, resizeContainer: null, // ==================== 修改:移除遮罩层,直接创建可拖动对话框 ==================== showDialog(data, download, lan){ if(!data||!(data.body instanceof Array)){ throw '数据错误'; } this.data = data; this.currentLan = lan || null; const settingDiv = elements.createAs('div',{ style :'position: fixed;top: 0;bottom: 0;left: 0;right: 0;background: transparent;pointer-events:none;z-index: 1048576;'+(download?'display:none':'') },document.body), panel = this.panelElement = elements.createAs('div',{ id:'subtitle-download-panel', style:'left:50%;top:50%;position:absolute;padding:15px;min-width:340px;max-width:calc(100vw - 20px);box-sizing:border-box;background:white;border-radius:8px;margin:auto;transform:translate(-50%,-50%);pointer-events:auto;box-shadow:0 4px 18px rgba(0,0,0,.18);' },settingDiv), header = elements.createAs('div',{ style:"position:relative;min-height:36px;margin-bottom:5px;cursor:move;user-select:none;line-height:1;", },panel); elements.createAs('span',{ innerText:'字幕批量下载复制查看器', style:'display:block;max-width:240px;white-space:nowrap;font-size:20px;line-height:24px;color:#00a1d6;font-weight:500;' },header); this.dialogElement = settingDiv; elements.createAs('a',{ href:'https://greasyfork.org/scripts/378513',target:'_blank', style:'position:absolute;right:0;top:0;color:#606060;font-size:13px;white-space:nowrap;', innerHTML:`当前版本:${typeof(GM_info)!="undefined"&&GM_info.script.version||'1.0'}` },header); elements.createAs('span',{ style:'position:absolute;right:0;top:19px;color:#99a2aa;font-size:12px;white-space:nowrap;', innerText:'窗口可拖动,点击⌜⌟可调整窗口大小' },header); const resizeContainer = this.resizeContainer = elements.createAs('div',{ className:'subtitle-textarea-resize-box', style:'position:relative;width:500px;min-width:310px;height:410px;min-height:180px;max-width:calc(100vw - 40px);max-height:calc(100vh - 170px);' },panel), textArea = this.textArea = elements.createAs('textarea',{ style:'width:100%;height:100%;box-sizing:border-box;resize:none;padding:5px;line-height:normal;border:1px solid #e5e9ef;margin:0px;' },resizeContainer), resizeHandle = elements.createAs('div',{ className:'subtitle-resize-handle', style:'position:absolute;right:3px;bottom:3px;width:32px;height:32px;border:2px solid #fff;border-radius:5px;background:#00a1d6;box-shadow:0 1px 5px rgba(0,0,0,.35);color:#fff;cursor:nwse-resize;display:flex;align-items:center;justify-content:center;z-index:2;user-select:none;', innerHTML:'', title:'点击或拖动此处调整字幕框大小', onmousedown:(e)=>e.stopPropagation() },resizeContainer); textArea.setAttribute('readonly',true); this.initTextAreaResize(resizeHandle, resizeContainer, panel); const availableLanguages = this.currentLan && typeof bilibiliCCHelper !== 'undefined' ? (bilibiliCCHelper.subtitle?.subtitles || []).filter(item=>item.lan !== 'close' && item.lan !== 'local') : []; if (availableLanguages.length) { const languagePanel = elements.createAs('div',{style:'font-size:14px; padding-top: 10px;'},panel); elements.createAs('span',{innerText:'语言:',style:'margin-right:5px;'},languagePanel); this.languageSelect = elements.createAs('select',{ style:'height: 24px; margin-right: 5px; min-width: 120px;', innerHTML:availableLanguages.map(item=>``).join(''), value:this.currentLan, onchange:(ev)=>this.changeLanguage(ev.target.value) },languagePanel); this.refreshButton = elements.createAs('a',{ innerText:'刷新', style:'height: 24px;margin-right: 5px;background: #00a1d6;color: #fff;padding: 7px;cursor: pointer;', onclick:(e)=>{e.preventDefault();e.stopPropagation();this.refreshCurrentSubtitle();} },languagePanel); this.languageStatus = elements.createAs('span',{style:'color:#99a2aa;font-size:12px;'},languagePanel); } else { this.languageSelect = null; this.languageStatus = null; } const bottomPanel = elements.createAs('div',{style:'font-size:14px; padding-top: 10px;'},panel); const type = localStorage.defaultSubtitleType || 'SRT'; this.formatSelect = elements.createAs('select',{ style:'height: 24px; margin-right: 5px;', innerHTML:['ASS','SRT','LRC','VTT','TXT','BCC'].map(type=>``).join(''), value:type, onchange:(ev)=>this.updateDownload(ev.target.value) },bottomPanel); this.actionButton = elements.createAs('a',{ title:'按住Ctrl键点击字幕列表的下载可不打开预览直接下载当前格式', innerText:'下载',style:'height: 24px;margin-right: 5px;background: #00a1d6;color: #fff;padding: 7px;', onclick:(e)=>e.stopPropagation(),oncontextmenu:(e)=>e.stopPropagation() },bottomPanel); this.batchButton = elements.createAs('a',{ innerText:'批量下载', style:'height: 24px;margin-right: 5px;background: #00a1d6;color: #fff;padding: 7px;cursor: pointer;', href:'javascript:', onclick:(e)=>{e.preventDefault();e.stopPropagation();bilibiliCCHelper.openBatchDialog();}, oncontextmenu:(e)=>e.stopPropagation() },bottomPanel); this.copyButton = elements.createAs('a',{ innerText:'复制', style:'height: 24px;margin-right: 5px;background: #00a1d6;color: #fff;padding: 7px;cursor: pointer;', href:'javascript:', onclick:(e)=>{e.preventDefault();e.stopPropagation();this.copyToClipboard();}, oncontextmenu:(e)=>e.stopPropagation() },bottomPanel); this.openTabButton = elements.createAs('a',{ innerText:'在新标签页中打开',style:'height: 24px;margin-right: 5px;background: #00a1d6;color: #fff;padding: 7px;', target:'_blank',onclick:(e)=>e.stopPropagation(),oncontextmenu:(e)=>e.stopPropagation() },bottomPanel); this.closeButton = elements.createAs('a',{ innerText:'关闭',style:'height: 24px;margin-right: 5px;background: #00a1d6;color: #fff;padding: 7px;cursor: pointer;', onclick:()=>document.body.removeChild(settingDiv) },bottomPanel); this.initDragging(header,panel); // 默认转换SRT格式 this.updateDownload(type, download); }, changeLanguage(lan){ if(!lan || typeof bilibiliCCHelper === 'undefined') return; this.currentLan = lan; if(this.languageStatus) this.languageStatus.innerText = '加载中…'; bilibiliCCHelper.getSubtitle(lan).then(data=>{ this.data = data; this.updateDownload(this.formatSelect ? this.formatSelect.value : 'SRT'); if(this.languageStatus) this.languageStatus.innerText = '已切换'; }).catch(e=>{ if(this.languageStatus) this.languageStatus.innerText = '加载失败'; bilibiliCCHelper.toast('切换字幕失败',e); }); }, refreshCurrentSubtitle(){ if(!this.currentLan || typeof bilibiliCCHelper === 'undefined') return; if(this.languageStatus) this.languageStatus.innerText = '刷新中…'; bilibiliCCHelper.setupData(true).then(()=>{ bilibiliCCHelper.datas = {close:{body:[]},local:{body:[]}}; return bilibiliCCHelper.getSubtitle(this.currentLan); }).then(data=>{ this.data = data; this.updateDownload(this.formatSelect ? this.formatSelect.value : 'SRT'); if(this.languageStatus) this.languageStatus.innerText = '已刷新'; }).catch(e=>{ if(this.languageStatus) this.languageStatus.innerText = '刷新失败'; bilibiliCCHelper.toast('刷新字幕失败',e); }); }, // 让右下角的蓝色手柄直接调整字幕文本框大小 initTextAreaResize(handle, container, panel){ const minWidth = 310; const minHeight = 180; let resizing = false; let startX = 0; let startY = 0; let startWidth = 0; let startHeight = 0; const onMove = (e) => { if(!resizing) return; const maxWidth = Math.max(minWidth, window.innerWidth - 40); const maxHeight = Math.max(minHeight, window.innerHeight - 170); const width = Math.max(minWidth, Math.min(maxWidth, startWidth + e.clientX - startX)); const height = Math.max(minHeight, Math.min(maxHeight, startHeight + e.clientY - startY)); container.style.width = width + 'px'; container.style.height = height + 'px'; if(panel) panel.style.width = Math.max(340, width + 30) + 'px'; }; const onUp = () => { if(!resizing) return; resizing = false; handle.classList.remove('active'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; handle.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); const rect = container.getBoundingClientRect(); resizing = true; startX = e.clientX; startY = e.clientY; startWidth = rect.width; startHeight = rect.height; handle.classList.add('active'); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }, // ==================== 修改:改进拖动功能 ==================== initDragging(handle, container){ const self = this; handle.addEventListener('mousedown', function(e){ // 排除点击按钮的情况 if(e.target.tagName === 'BUTTON' || e.target.tagName === 'A' || e.target.innerHTML === '×') return; // 阻止事件冒泡,防止被其他元素拦截 e.stopPropagation(); self.isDragging = true; const rect = container.getBoundingClientRect(); self.dragOffsetX = e.clientX - rect.left; self.dragOffsetY = e.clientY - rect.top; // 改变光标样式 handle.style.cursor = 'grabbing'; container.style.transition = 'none'; e.preventDefault(); }); // 使用全局事件监听,确保拖动流畅 const onMouseMove = function(e){ if(!self.isDragging) return; const x = e.clientX - self.dragOffsetX; const y = e.clientY - self.dragOffsetY; // 限制在视窗内 const maxX = window.innerWidth - container.offsetWidth; const maxY = window.innerHeight - container.offsetHeight; const clampedX = Math.max(0, Math.min(x, maxX)); const clampedY = Math.max(0, Math.min(y, maxY)); container.style.left = clampedX + 'px'; container.style.top = clampedY + 'px'; container.style.transform = 'none'; }; const onMouseUp = function(){ if(self.isDragging){ self.isDragging = false; handle.style.cursor = 'move'; container.style.transition = 'transform 0.2s, left 0.2s, top 0.2s'; } }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); // 保存监听器引用以便清理(可选) this._dragMoveHandler = onMouseMove; this._dragUpHandler = onMouseUp; }, // ==================== 新增:一键复制功能 ==================== copyToClipboard(){ const text = this.textArea.value; if(!text || text.length === 0){ this.showToast('没有可复制的内容!', 'error'); return; } try { // 使用GM_setClipboard(油猴API)或navigator.clipboard if(typeof GM_setClipboard !== 'undefined'){ GM_setClipboard(text, 'text'); this.showCopySuccess(); } else if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(text).then(()=>{ this.showCopySuccess(); }).catch(err=>{ this.fallbackCopy(text); }); } else { this.fallbackCopy(text); } } catch(e) { this.fallbackCopy(text); } }, // 备用复制方案 fallbackCopy(text){ const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-9999px'; textArea.style.top = '0'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { const successful = document.execCommand('copy'); if(successful){ this.showCopySuccess(); } else { this.showToast('❌ 复制失败,请手动复制', 'error'); } } catch(err) { this.showToast('❌ 复制失败,请手动复制', 'error'); } document.body.removeChild(textArea); }, showCopySuccess(){ this.showToast('✅ 复制成功!已保存到剪贴板'); if(this.copyButton){ const button = this.copyButton; button.innerText = '已复制 ✓'; button.style.background = '#20b26b'; clearTimeout(this.copyResetTimer); this.copyResetTimer = setTimeout(()=>{ if(button.parentNode){ button.innerText = '复制'; button.style.background = '#00a1d6'; } }, 1800); } }, // 显示提示 showToast(message, type='success'){ // 移除已存在的toast const existing = document.querySelector('.copy-success-toast'); if(existing) existing.remove(); const toast = elements.createAs('div',{ className: 'copy-success-toast', innerText: message, style:`position:fixed;top:24px;left:50%;transform:translateX(-50%);z-index:1048579;background:${type==='error'?'#e85d5d':'#20b26b'};color:#fff;padding:12px 22px;border-radius:6px;font-size:14px;line-height:1.4;box-shadow:0 3px 12px rgba(0,0,0,.28);pointer-events:none;white-space:nowrap;animation:none;` },document.body); setTimeout(()=>{ if(toast.parentNode) toast.parentNode.removeChild(toast); }, 2000); }, updateDownload(type='LRC', download){ let result; let blobResult switch(type) { case 'LRC': result = this.encodeToLRC(this.data.body); break; case 'SRT': result = this.encodeToSRT(this.data.body); break; case 'ASS': result = this.encodeToASS(this.data.body); break; case 'VTT': result = this.encodeToVTT(this.data.body); break; case 'TXT': result = this.data.body.map(item=>item.content).join('\r\n'); break; case 'BCC': result = JSON.stringify(this.data,undefined,2); break; default: result = '错误:无法识别的格式 ' + type; break; } this.textArea.value = result; localStorage.defaultSubtitleType = type; type = type.toLowerCase(); URL.revokeObjectURL(this.actionButton.href); this.actionButton.classList.remove('bpui-state-disabled','bui-button-disabled'); blobResult = new Blob([result],{type:'text/'+type+';charset=utf-8'}) this.actionButton.href = URL.createObjectURL(blobResult); this.openTabButton.href = URL.createObjectURL(blobResult); this.actionButton.download = `${bilibiliCCHelper.getInfo('h1Title') || document.title}.${type}`; if (download) { this.actionButton.click(); this.closeButton.click(); } }, encodeToLRC(data){ return data.map(({from,to,content})=>{ return `${this.encodeTime(from,'LRC')} ${content.replace(/\n/g,' ')}`; }).join('\r\n'); }, encodeToSRT(data){ return data.map(({from,to,content},index)=>{ return `${index+1}\r\n${this.encodeTime(from)} --> ${this.encodeTime(to)}\r\n${content}`; }).join('\r\n\r\n'); }, encodeToVTT(data){ return 'WEBVTT \r\n\r\n' + data.map(({from,to,content},index)=>{ return `${index+1}\r\n${this.encodeTime(from, 'VTT')} --> ${this.encodeTime(to, 'VTT')}\r\n${content}`; }).join('\r\n\r\n'); }, encodeToASS(data){ this.assHead[1] = `Title: ${document.title}`; this.assHead[10] = `; 字幕来源${document.location}`; return this.assHead.concat(data.map(({from,to,content})=>{ return `Dialogue: 0,${this.encodeTime(from,'ASS')},${this.encodeTime(to,'ASS')},*Default,NTP,0000,0000,0000,,${content.replace(/\n/g,'\\N')}`; })).join('\r\n'); }, encodeTime(input,format='SRT'){ let time = new Date(input*1000), ms = time.getMilliseconds(), second = time.getSeconds(), minute = time.getMinutes(), hour = Math.floor(input/60/60); if (format=='SRT'||format=='VTT'){ if (hour<<10) hour = '0'+hour; if (minute<<10) minute = '0'+minute; if (second<<10) second = '0'+second; if (ms<<10) ms = '00'+ms; else if (ms<<100) ms = '0'+ms; return `${hour}:${minute}:${second}${format=='SRT'?',':'.'}${ms}`; } else if(format=='ASS'){ ms = (ms/10).toFixed(0); if (minute<<10) minute = '0'+minute; if (second<<10) second = '0'+second; if (ms<<10) ms = '0'+ms; return `${hour}:${minute}:${second}.${ms}`; } else{ ms = (ms/10).toFixed(0); minute += hour*60; if (minute<<10) minute = '0'+minute; if (second<<10) second = '0'+second; if (ms<<10) ms = '0'+ms; return `[${minute}:${second}.${ms}]`; } } }; //解码器,用于读取常见格式字幕并将其转换为B站可以读取BCC格式字幕 const decoder = { srtReg:/(?:(\d+):)?(\d{1,2}):(\d{1,2})[,\.](\d{1,3})\s*(?:-->|,)\s*(?:(\d+):)?(\d{1,2}):(\d{1,2})[,\.](\d{1,3})\r?\n([.\s\S]+)/, assReg:/Dialogue:.*,(\d+):(\d{1,2}):(\d{1,2}\.?\d*),\s*?(\d+):(\d{1,2}):(\d{1,2}\.?\d*)(?:.*?,){7}(.+)/, encodings:['UTF-8','GB18030','BIG5','UNICODE','JIS','EUC-KR'], encoding:'UTF-8', dialog:undefined, reader:undefined, file:undefined, data:undefined, statusHandler:undefined, // ==================== 新增:拖动相关变量 ==================== isDragging: false, dragOffsetX: 0, dragOffsetY: 0, show(handler){ this.statusHandler = handler; if(!this.dialog){ this.moveAction = ev=>this.dialogMove(ev); this.dialog = elements.createAs('div',{ id :'subtitle-local-selector', style :'position:fixed;z-index:1048576;padding:10px;top:50%;left:calc(50% - 185px);box-shadow: 0 0 4px #e5e9ef;border: 1px solid #e5e9ef;background:white;border-radius:5px;color:#99a2aa' },elements.getAs('#bilibiliPlayer')); // 标题栏,保留拖动功能 const header = elements.createAs('div',{ style:"margin-bottom: 5px;cursor:move;user-select:none;line-height:1;", innerText:'本地字幕选择' },this.dialog); elements.createAs('input',{ style: "margin-bottom: 5px;width: 370px;", innerText:'选择字幕', type: 'file',accept:'.lrc,.ass,.ssa,.srt,.bcc,.sbv,.vtt', oninput: ({target})=> this.readFile(this.file = target.files&&target.files[0]) },this.dialog); elements.createAs('br',{},this.dialog); elements.createAs('label',{style: "margin-right: 10px;",innerText: '字幕编码'},this.dialog); elements.createAs('select',{ style: "width: 80px;height: 20px;border-radius: 4px;line-height: 20px;border:1px solid #ccd0d7;", title:'如果字幕乱码可尝试更改编码', innerHTML:this.encodings.reduce((result,item)=>`${result}`,''), oninput: ({target})=> this.readFile(this.encoding = target.value) },this.dialog); elements.createAs('label',{ style: "margin-left: 10px;",innerText: '时间偏移(s)',title:'字幕相对于视频的时间偏移,双击此标签复位时间偏移', ondblclick:()=> +this.offset.value&&this.handleSubtitle(this.offset.value=0) },this.dialog); this.offset = elements.createAs('input',{ style: "margin-left: 10px;width: 50px;border: 1px solid #ccd0d7;border-radius: 4px;line-height: 20px;", type:'number', step:0.5, value:0, title:'负值表示将字幕延后,正值将字幕提前', oninput: ()=> this.handleSubtitle() },this.dialog); elements.createAs('button',{ style: "margin-left: 10px;border:none;width:max-content;",innerText: '关闭面板', className:'bpui-button bui bui-button bui-button-blue', onclick: ()=> elements.getAs('#bilibiliPlayer').removeChild(this.dialog) },this.dialog); this.reader = new FileReader(); this.reader.onloadend = ()=> this.decodeFile() this.reader.onerror = e=> bilibiliCCHelper.toast('载入字幕失败',e); this.initDragging(header, this.dialog); } else{ elements.getAs('#bilibiliPlayer').appendChild(this.dialog); this.handleSubtitle(); } }, // ==================== 修改:改进本地字幕对话框拖动 ==================== initDragging(handle, container){ const self = this; handle.addEventListener('mousedown', function(e){ if(e.target.innerHTML === '×') return; // 排除关闭按钮 // 阻止事件冒泡 e.stopPropagation(); self.isDragging = true; const rect = container.getBoundingClientRect(); self.dragOffsetX = e.clientX - rect.left; self.dragOffsetY = e.clientY - rect.top; handle.style.cursor = 'grabbing'; container.style.transition = 'none'; e.preventDefault(); }); const onMouseMove = function(e){ if(!self.isDragging) return; const x = e.clientX - self.dragOffsetX; const y = e.clientY - self.dragOffsetY; // 限制在视窗内 const maxX = window.innerWidth - container.offsetWidth; const maxY = window.innerHeight - container.offsetHeight; container.style.left = Math.max(0, Math.min(x, maxX)) + 'px'; container.style.top = Math.max(0, Math.min(y, maxY)) + 'px'; container.style.transform = 'none'; }; const onMouseUp = function(){ if(self.isDragging){ self.isDragging = false; handle.style.cursor = 'move'; } }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }, dialogMove(ev){ // 已废弃,改用新的拖动系统 }, readFile(){ if(!this.file) { this.data = undefined; return bilibiliCCHelper.toast('没有文件'); } this.reader.readAsText(this.file,this.encoding) }, handleSubtitle(){ if(!this.data) return; const offset = +this.offset.value; bilibiliCCHelper.updateLocal(!offset?this.data:{ body:this.data.body.map(({from,to,content})=>({ from:from - offset, to:to - offset, content })) }).then(()=>{ if('function'==typeof(this.statusHandler)) this.statusHandler(true); bilibiliCCHelper.toast(`载入本地字幕:${this.file.name},共${this.data.body.length}行,偏移:${offset}s`); }).catch(e=>{ bilibiliCCHelper.toast('载入字幕失败',e); }); }, decodeFile(){ try{ const type = this.file.name.split('.').pop().toLowerCase(); switch(type){ case 'lrc':this.data = this.decodeFromLRC(this.reader.result);break; case 'ass':case 'ssa': this.data = this.decodeFromASS(this.reader.result);break; case 'srt':case 'sbv':case 'vtt': this.data = this.decodeFromSRT(this.reader.result);break; case 'bcc':this.data = JSON.parse(this.reader.result);break; default:throw('未知文件类型'+type);break; } console.log(this.data); this.handleSubtitle(); } catch(e){ bilibiliCCHelper.toast('解码字幕文件失败',e); }; }, decodeFromLRC(input){ if(!input) return; const data = []; input.split('\n').forEach(line=>{ let match = line.match(/((\[\d+:\d+\.?\d*\])+)(.*)/); if (!match) { if(match=line.match(/\[offset:(\d+)\]/i)) { this.offset.value = +match[1]/1000; } return; } const times = match[1].match(/\d+:\d+\.?\d*/g); times.forEach(time=>{ const t = time.split(':'); data.push({ time:t[0]*60 + (+t[1]), content:match[3].trim().replace('\r','') }); }); }); return { body:data.sort((a,b)=>a.time-b.time).map((item,index)=>( item.content!=''&&{ from:item.time, to:index==data.length-1?item.time+20:data[index+1].time, content:item.content } )).filter(item=>item) }; }, decodeFromSRT(input){ if(!input) return; const data = []; let split = input.split('\n\n'); if(split.length==1) split = input.split('\r\n\r\n'); split.forEach(item=>{ const match = item.match(this.srtReg); if (!match){ return; } data.push({ from:(match[1]*60*60||0) + match[2]*60 + (+match[3]) + (match[4]/1000), to:(match[5]*60*60||0) + match[6]*60 + (+match[7]) + (match[8]/1000), content:match[9].trim().replace(/{\\.+?}/g,'').replace(/\\N/gi,'\n').replace(/\\h/g,' ') }); }); return {body:data.sort((a,b)=>a.from-b.from)}; }, decodeFromASS(input){ if(!input) return; const data = []; let split = input.split('\n'); split.forEach(line=>{ const match = line.match(this.assReg); if (!match){ return; } data.push({ from:match[1]*60*60 + match[2]*60 + (+match[3]), to:match[4]*60*60 + match[5]*60 + (+match[6]), content:match[7].trim().replace(/{\\.+?}/g,'').replace(/\\N/gi,'\n').replace(/\\h/g,' ') }); }); return {body:data.sort((a,b)=>a.from-b.from)}; } }; //旧版播放器CC字幕助手... const oldPlayerHelper = { setting:undefined, subtitle:undefined, selectedLan:undefined, isclosed:true, resizeRate: 100, configs:{ color:[ {value:'16777215',content:'<白色'}, {value:'16007990',content:'<红色'}, {value:'10233776',content:'<紫色'}, {value:'6765239',content:'<深紫色'}, {value:'4149685',content:'<靛青色'}, {value:'2201331',content:'<蓝色'}, {value:'240116',content:'<亮蓝色'} ], position:[ {value:'bl',content:'左下角'}, {value:'bc',content:'底部居中'}, {value:'br',content:'右下角'}, {value:'tl',content:'左上角'}, {value:'tc',content:'顶部居中'}, {value:'tr',content:'右上角'} ], shadow:[ {value:'0',content:'无描边',style:''}, {value:'1',content:'重墨',style:`text-shadow: #000 1px 0px 1px, #000 0px 1px 1px, #000 0px -1px 1px,#000 -1px 0px 1px;`}, {value:'2',content:'描边',style:`text-shadow: #000 0px 0px 1px, #000 0px 0px 1px, #000 0px 0px 1px;`}, {value:'3',content:'45°投影',style:`text-shadow: #000 1px 1px 2px, #000 0px 0px 1px;`} ] }, saveSetting(){ try{ const playerSetting = localStorage.bilibili_player_settings?JSON.parse(localStorage.bilibili_player_settings):{}; playerSetting.subtitle = this.setting; localStorage.bilibili_player_settings = JSON.stringify(playerSetting); }catch(e){ bilibiliCCHelper.toast('保存字幕设置错误',e); } }, changeStyle(){ this.fontStyle.innerHTML = `span.subtitle-item-background{opacity: ${this.setting.backgroundopacity};}` + `span.subtitle-item-text {color:#${("000000"+this.setting.color.toString(16)).slice(-6)};}` + `span.subtitle-item {font-size: ${this.setting.fontsize*this.resizeRate}%;line-height: 110%;}` + `span.subtitle-item {${this.configs.shadow[this.setting.shadow].style}}`; }, changePosition(){ this.subtitleContainer.className = 'subtitle-position subtitle-position-' +(this.setting.position||'bc'); this.subtitleContainer.style = ''; }, changeResize(){ this.resizeRate = this.setting.scale?bilibiliCCHelper.window.player.getWidth()/1280*100:100; this.changeStyle(); }, changeSubtitle(value=this.subtitle.subtitles[0].lan){ this.selectedLanguage.innerText = bilibiliCCHelper.getSubtitleInfo(value).lan_doc; if(value=='close'){ if(!this.isclosed) { this.isclosed = true; bilibiliCCHelper.loadSubtitle(value); if(this.selectedLan!='local') this.setting.isclosed = true; } this.downloadBtn.classList.add('bpui-state-disabled','bpui-button-icon'); this.icon.innerHTML = elements.oldDisableIcon; } else if(value=='local') { decoder.show((status)=>{ if(status==true){ this.downloadBtn.classList.remove('bpui-state-disabled','bpui-button-icon'); this.isclosed = false; this.selectedLan = value; this.icon.innerHTML = elements.oldEnableIcon; } }); } else{ this.isclosed = false; this.selectedLan = value; this.icon.innerHTML = elements.oldEnableIcon; this.setting.lan = value; this.setting.isclosed = false; bilibiliCCHelper.loadSubtitle(value); this.downloadBtn.classList.remove('bpui-state-disabled','bpui-button-icon'); } }, toggleSubtitle(){ if(this.isclosed) { this.changeSubtitle(this.selectedLan); } else{ this.changeSubtitle('close'); } }, initSubtitle(){ if(this.setting.isclosed) { this.changeSubtitle('close'); } else{ const lan = bilibiliCCHelper.getSubtitleInfo(this.setting.lan)&&this.setting.lan this.changeSubtitle(lan); } if(!this.subtitle.count) this.selectedLan = 'local'; this.changeResize(); }, initUI(){ const preBtn = elements.getAs('.bilibili-player-video-btn-quality'); if(!preBtn) throw('没有找到视频清晰度按钮'); this.subtitleContainer = elements.getAs('.bilibili-player-video-subtitle>div'); const btn = preBtn.insertAdjacentElement('afterEnd',elements.createAs('div',{ className:"bilibili-player-video-btn", id:'bilibili-player-subtitle-btn', style:"display: block;", innerHTML:elements.subtitleStyle, onclick:(e)=>{ if(!this.panel.contains(e.target)) this.toggleSubtitle(); } })); this.icon = elements.createAs('span',{ innerHTML: this.setting.isclosed?elements.oldDisableIcon:elements.oldEnableIcon },btn); this.fontStyle = elements.createAs('style',{type:"text/css"},btn); const panel = this.panel = elements.createAs('div',{ id:'subtitle-setting-panel', style:'position: absolute;bottom: 28px;right: 30px;background: white;border-radius: 4px;text-align: left;padding: 13px;display: none;cursor:default;' },btn), languageDiv = elements.createAs('div',{innerHTML:'<
字幕
'},panel), sizeDiv = elements.createAs('div',{innerHTML:'<
字体大小
'},panel), colorDiv = elements.createAs('div',{innerHTML:'<字幕颜色'},panel), shadowDiv = elements.createAs('div',{innerHTML:'<字幕描边'},panel), positionDiv = elements.createAs('div',{innerHTML:'<字幕位置'},panel), opacityDiv = elements.createAs('div',{innerHTML:'<
背景不透明度
'},panel); this.selectedLanguage = elements.createSelector({ width:'100px',height:'180px',initValue:'close', handler:(value)=>this.changeSubtitle(value), datas:this.subtitle.subtitles.map(({lan,lan_doc})=>({content:lan_doc,value:lan})) },languageDiv); this.downloadBtn = elements.createAs('button',{ className: "bpui-button",style: 'padding:0 8px;', innerText: "下载", onclick: (ev)=>{ if(this.selectedLan=='close') return; bilibiliCCHelper.downloadSubtitle(this.selectedLan, undefined, ev.ctrlKey); } },languageDiv); elements.createAs('a',{ className: this.subtitle.allow_submit?'bpui-button':'bpui-button bpui-state-disabled', innerText: '添加字幕', href: !this.subtitle.allow_submit?'javascript:':`https://member.bilibili.com/v2#/zimu/my-zimu/zimu-editor?cid=${window.cid}&${window.aid?`aid=${window.aid}`:`bvid=${window.bvid}`}`, target: '_blank', style: 'margin-right: 0px;height: 24px;padding:0 6px;', title: this.subtitle.allow_submit?'':'本视频无法添加字幕', },languageDiv); elements.createAs('input',{ style:"width: 70%;",type:"range",step:"25", value: (this.setting.fontsize==0.6?0:this.setting.fontsize==0.8?25:this.setting.fontsize==1.3?75:this.setting.fontsize==1.6?100:50), oninput:(e)=>{ const v = e.target.value/25; this.setting.fontsize = v>2?(v-2)*0.3+1:v*0.2+0.6; this.changeStyle(); } },sizeDiv); elements.createAs('input',{ id:'subtitle-auto-resize', type:"checkbox", checked:this.setting.scale, onchange:(e)=>this.changeResize(this.setting.scale = e.target.checked) },sizeDiv); elements.createAs('label',{ style:"cursor:pointer", innerText:'自动缩放' },sizeDiv).setAttribute('for','subtitle-auto-resize'); elements.createSelector({ width:'74%',height:'120px', initValue:this.setting.color, handler:(value)=>this.changeStyle(this.setting.color=parseInt(value)), datas:this.configs.color },colorDiv); elements.createSelector({ width:'74%',height:'120px', initValue:this.setting.shadow, handler:(value)=>this.changeStyle(this.setting.shadow=value), datas:this.configs.shadow },shadowDiv); elements.createSelector({ width:'74%',initValue:this.setting.position, handler:(value)=>this.changePosition(this.setting.position=value), datas:this.configs.position },positionDiv); elements.createAs('input',{ style:"width: 100%;", type:"range", value: this.setting.backgroundopacity*100, oninput:(e)=>{ this.changeStyle(this.setting.backgroundopacity = e.target.value/100); } },opacityDiv); bilibiliCCHelper.window.player.addEventListener('video_resize', (event) => { this.changeResize(event); }); bilibiliCCHelper.window.addEventListener("beforeunload", (event) => { this.saveSetting(); }); this.initSubtitle(); console.log('init cc helper button done'); }, init(subtitle){ this.subtitle = subtitle; this.selectedLan = undefined; try { if (!localStorage.bilibili_player_settings) throw '当前播放器没有设置信息'; this.setting = JSON.parse(localStorage.bilibili_player_settings).subtitle; if (!this.setting) throw '当前播放器没有字幕设置'; }catch (e) { bilibiliCCHelper.toast('bilibili CC字幕助手读取设置出错,将使用默认设置:', e); this.setting = {backgroundopacity: 0.5,color: 16777215,fontsize: 1,isclosed: false,scale: true,shadow: "0", position: 'bc'}; } this.initUI(); } }; //2.x播放器CC字幕助手... const player2x = { iconBtn:undefined, icon:undefined, panel:undefined, downloadBtn:undefined, selectedLan:undefined, selectedLocal:false, hasSubtitles:false, updateDownloadBtn(value='close'){ this.selectedLan = value; if(value=='close'){ this.downloadBtn.classList.add('bui-button-disabled','bpui-button-icon'); } else{ this.selectedLocal = false; this.downloadBtn.classList.remove('bui-button-disabled','bpui-button-icon'); } }, initUI(){ const downloadBtn = this.downloadBtn = this.panel.nextElementSibling.cloneNode(), selector = this.panel.querySelector('ul'), selectedItem = selector.querySelector('li.bui-select-item.bui-select-item-active'), closeItem = selector.querySelector('li.bui-select-item[data-value="close"]'), localItem = closeItem.cloneNode(); elements.setAs(downloadBtn,{ style: 'min-width:unset!important',innerText: '下载', onclick: (ev)=>{ if(this.selectedLan=='close') return; bilibiliCCHelper.downloadSubtitle(this.selectedLan, undefined, ev.ctrlKey); } }); this.panel.insertAdjacentElement('afterend',downloadBtn); this.updateDownloadBtn(selectedItem&&selectedItem.dataset.value); elements.setAs(localItem,{ innerText: '本地字幕', onclick: ()=> { decoder.show((status)=>{ if(status==true){ this.selectedLocal = true; this.updateDownloadBtn('local'); this.icon.innerHTML = elements.newEnableIcon; } }); } },selector); closeItem.addEventListener('click',()=>{ if(!this.selectedLocal) return; this.selectedLocal = false; bilibiliCCHelper.loadSubtitle('close'); this.icon.innerHTML = elements.newDisableIcon; }); if(!this.hasSubtitles && this.icon){ this.icon.innerHTML = elements.newDisableIcon; this.icon.addEventListener('click',({target})=>{ if(!this.selectedLocal) localItem.click(); else closeItem.click(); }); } new MutationObserver((mutations,observer)=>{ mutations.forEach(mutation=>{ if(!mutation.target||mutation.type!='attributes') return; if(mutation.target.classList.contains('bui-select-item-active')&&mutation.target.dataset.value){ this.updateDownloadBtn(mutation.target.dataset.value); } }); }).observe(selector,{ subtree: true, attributes: true, attributeFilter: ['class'] }); console.log('Bilibili CC Helper init new UI success.'); }, initUI275(){ if (this.localPanel = this.panel.querySelector('.bilibili-player-video-subtitle-setting-item-body')) { if (!(this.localButton = this.localPanel.querySelector('.bilibili-player-video-subtitle-setting-title'))) { this.localPanel.insertAdjacentElement('afterbegin', elements.createAs('div', { innerText: '字幕', className: 'bilibili-player-video-subtitle-setting-title', onclick:()=> decoder.show(status=>(status && (this.icon.innerHTML = elements.newEnableIcon))) })); } else { this.localButton.onclick = ()=> decoder.show(status=>{ if (status) { this.selectedLocal = true; this.icon.innerHTML = elements.newEnableIcon; } }) } } if (this.lngPanel = this.panel.querySelector('.bilibili-player-video-subtitle-setting-lan-majorlist')) { this.lngPanel.addEventListener('click', function(ev) { if (!(ev.target instanceof HTMLLIElement) || ev.target.lastChild.data=='本地字幕') return; const rect = ev.target.getBoundingClientRect().right; if (rect ==0 || rect -ev.x > 30) return; bilibiliCCHelper.downloadSubtitle(undefined, ev.target.lastChild.data, ev.ctrlKey); return false; }); } elements.createAs('style', { innerHTML:'.bilibili-player-video-subtitle-setting-lan-majorlist>li.bilibili-player-video-subtitle-setting-lan-majorlist-item:after {content: "下载";right: 12px;position: absolute;}' +'.bilibili-player-video-subtitle-setting-title {cursor:pointer}.bilibili-player-video-subtitle-setting-title:before {content: "本地"}' }, this.panel); if(!this.hasSubtitles) { this.icon.onclick = ()=>{ if (this.selectedLocal) { this.selectedLocal = false; bilibiliCCHelper.loadSubtitle('close'); this.icon.innerHTML = elements.newDisableIcon; } else { this.localButton.click(); } }; this.icon.innerHTML = elements.newDisableIcon; } console.log('Bilibili CC Helper init new 2.75 UI success.'); }, init(subtitle){ this.hasSubtitles = subtitle.count; this.selectedLan = undefined; this.selectedLocal = false; this.iconBtn = elements.getAs('.bilibili-player-video-btn-subtitle'); this.panel = elements.getAs('.bilibili-player-video-subtitle-setting-lan'); this.icon = this.iconBtn.querySelector('.bilibili-player-iconfont-subtitle span'); elements.createAs('style', {innerHTML:'.bilibili-player-video-subtitle {z-index: 20;}'}, document.head); if(this.panel){ this.initUI(); this.iconBtn.id = 'bilibili-player-subtitle-btn'; } else if(this.iconBtn){ this.iconBtn.style = 'display:block'; if(!this.hasSubtitles&&this.icon) this.icon.innerHTML = elements.newDisableIcon; this.iconBtn.id = 'bilibili-player-subtitle-btn'; new MutationObserver((mutations,observer)=>{ for (const mutation of mutations){ if(!mutation.target) continue; if (mutation.target.classList.contains('bilibili-player-video-subtitle-setting-left')){ observer.disconnect(); if (this.panel = mutation.target.querySelector('.bilibili-player-video-subtitle-setting-lan')) { this.initUI(); } else { this.panel = mutation.target; this.initUI275(); } return; } } }).observe(this.iconBtn,{ childList: true, subtree: true }); } else{ throw('找不到新播放器按钮'); } }, }; // 3.15新版播放器... const player315 = { panel:undefined, initUI(){ elements.createAs('style',{ innerHTML:'.bpx-player-ctrl-subtitle-major-inner>.bpx-player-ctrl-subtitle-language-item:after {content: "下载";position:absolute;right:12px; margin-top:12px;}' }, this.panel); this.panel.addEventListener('click', function(ev) { if ((!ev.target || !ev.target.classList.contains('bpx-player-ctrl-subtitle-language-item'))) return; const rect = ev.target.getBoundingClientRect().right; if (rect ==0 || rect -ev.x > 30) return; ev.preventDefault(); ev.stopPropagation(); bilibiliCCHelper.downloadSubtitle(ev.target.dataset.lan, ev.target.lastChild.data, ev.ctrlKey); return false; }, true); this.panel.id = 'bilibili-player-subtitle-btn'; console.log('3.15 Bilibili CC Helper init new Bangumi UI success.'); }, init(subtitle){ this.panel = elements.getAs('.bpx-player-ctrl-subtitle-major-content'); if (!this.panel) { throw('无字幕'); } this.initUI(); }, }; //3.14版番剧播放器... const player314 = { iconBtn:undefined, icon:undefined, panel:undefined, selectedLan:undefined, selectedLocal:false, hasSubtitles:false, updateBtnIcon(value) { if (value) { this.icon.classList.add('squirtle-subtitle-show-state'); this.icon.classList.remove('squirtle-subtitle-hide-state'); } else { this.icon.classList.add('squirtle-subtitle-hide-state'); this.icon.classList.remove('squirtle-subtitle-show-state'); } }, initUI(){ elements.createAs('style', {innerHTML:'.squirtle-subtitle-select-list>li.squirtle-select-item:after {content: "下载";}'}, document.head); this.panel.addEventListener('click', function(ev) { if (!(ev.target instanceof HTMLLIElement)) return; const rect = ev.target.getBoundingClientRect().right; if (rect ==0 || rect -ev.x > 30) return; const subtitleName = ev.target.lastChild.data; bilibiliCCHelper.getSubtitle(undefined, subtitleName).then(data=>{ const item = bilibiliCCHelper.getSubtitleInfo(undefined, subtitleName); encoder.showDialog(data,ev.ctrlKey,item && item.lan); }).catch(e=>{ bilibiliCCHelper.toast('获取字幕失败',e); }); return false; }); this.panel.id = 'bilibili-player-subtitle-btn'; console.log('Bilibili CC Helper init new Bangumi UI success.'); }, init(subtitle){ this.hasSubtitles = subtitle.count; this.selectedLan = undefined; this.selectedLocal = false; this.iconBtn = elements.getAs('.squirtle-subtitle-wrap'); this.panel = elements.getAs('.squirtle-subtitle-select-list'); this.icon = this.iconBtn.querySelector('.squirtle-subtitle-icon'); if (!this.iconBtn) { throw('找不到新播放器按钮'); } if(this.panel) this.initUI(); }, }; //启动器 const bilibiliCCHelper = { window:"undefined"==typeof(unsafeWindow)?window:unsafeWindow, player:undefined, cid:undefined, subtitle:undefined, datas:undefined, menuCommandsRegistered:false, floatButton:null, floatButtonHiddenThisPage:false, floatButtonPrefKey:'bilibili_cc_float_button_hidden_v1', registerMenuCommands(){ if(this.menuCommandsRegistered || typeof GM_registerMenuCommand !== 'function') return; this.menuCommandsRegistered = true; GM_registerMenuCommand('打开字幕下载窗口', ()=>this.openDownloadDialog()); GM_registerMenuCommand('批量下载字幕', ()=>this.openBatchDialog()); GM_registerMenuCommand('打开/关闭悬浮按钮(全局设置)', ()=>this.toggleFloatingButtonGlobal()); }, createFloatingButton(){ if(this.floatButton || !document.body) return; const self = this; this.floatButton = elements.createAs('div', { id:'cc-subtitle-download-trigger', style:'position:fixed;top:110px;right:24px;z-index:1048577;width:44px;height:44px;background:linear-gradient(135deg,#00a1d6,#00b5e5);border-radius:12px;box-shadow:0 4px 14px rgba(0,161,214,0.35);cursor:pointer;display:flex;align-items:center;justify-content:center;color:#fff;font-size:22px;user-select:none;-webkit-tap-highlight-color:transparent;', innerHTML:'📜', title:'字幕下载 — 左键打开,右键设置', onmouseenter:function(){ this.style.transform='scale(1.1)'; this.style.boxShadow='0 6px 20px rgba(0,161,214,0.55)'; }, onmouseleave:function(){ this.style.transform='scale(1)'; this.style.boxShadow='0 4px 14px rgba(0,161,214,0.35)'; }, onclick:function(){ self.openDownloadDialog(); }, oncontextmenu:function(e){ e.preventDefault(); e.stopPropagation(); self.showFloatingButtonMenu(e.clientX, e.clientY); return false; } }, document.body); this.applyFloatingButtonVisibility(); }, applyFloatingButtonVisibility(){ if(!this.floatButton) return; const state = uiManager.loadState(this.floatButtonPrefKey,{hidden:false}); this.floatButton.style.display = this.floatButtonHiddenThisPage || state.hidden ? 'none' : 'flex'; }, toggleFloatingButtonGlobal(){ const state = uiManager.loadState(this.floatButtonPrefKey,{hidden:false}); uiManager.saveState(this.floatButtonPrefKey,{hidden:!state.hidden}); this.floatButtonHiddenThisPage = false; this.applyFloatingButtonVisibility(); this.toast(state.hidden ? '悬浮按钮已打开' : '悬浮按钮已关闭'); }, hideFloatingButtonTemporarily(){ this.floatButtonHiddenThisPage = true; this.applyFloatingButtonVisibility(); this.toast('悬浮按钮已临时关闭'); }, hideFloatingButtonPermanently(){ uiManager.saveState(this.floatButtonPrefKey,{hidden:true}); this.floatButtonHiddenThisPage = true; this.applyFloatingButtonVisibility(); this.toast('悬浮按钮已永久关闭,可从插件菜单重新打开'); }, showFloatingButtonMenu(x,y){ const oldMenu = document.getElementById('cc-subtitle-button-menu'); oldMenu && oldMenu.remove(); const menu = elements.createAs('div', { id:'cc-subtitle-button-menu', style:'position:fixed;z-index:1048578;min-width:190px;padding:6px 0;background:#fff;border:1px solid #e5e9ef;border-radius:4px;box-shadow:0 4px 16px rgba(0,0,0,.18);font-size:14px;color:#18191c;' }, document.body); const addItem = (label, handler) => elements.createAs('div', { innerText:label, style:'padding:9px 14px;cursor:pointer;white-space:nowrap;', onmouseenter:function(){ this.style.background='#f1f2f3'; }, onmouseleave:function(){ this.style.background='#fff'; }, onclick:function(e){ e.stopPropagation(); menu.remove(); handler(); } }, menu); addItem('打开字幕下载窗口', ()=>this.openDownloadDialog()); addItem('批量下载字幕', ()=>this.openBatchDialog()); addItem('临时关闭悬浮按钮(本页)', ()=>this.hideFloatingButtonTemporarily()); addItem('永久关闭悬浮按钮', ()=>this.hideFloatingButtonPermanently()); const width = 210; const height = 122; menu.style.left = Math.max(8, Math.min(x, window.innerWidth - width - 8)) + 'px'; menu.style.top = Math.max(8, Math.min(y, window.innerHeight - height - 8)) + 'px'; const close = (e) => { if(!menu.contains(e.target)) { menu.remove(); document.removeEventListener('mousedown', close, true); } }; setTimeout(()=>document.addEventListener('mousedown', close, true), 0); }, getBatchItems(){ const state = this.window.__INITIAL_STATE__ || {}; const videoData = state.videoData || this.getInfo('videoData') || {}; const current = { bvid:this.getInfo('bvid') || this.bvid, aid:this.getInfo('aid') || this.aid, cid:this.cid, ep_id:this.epid, title:this.getInfo('h1Title') || document.title }; const normalize = (raw,index,defaults={}) => { if(!raw) return null; const source = raw.episode || raw.video || raw; const bvid = source.bvid || source.bv_id || raw.bvid || defaults.bvid; const aid = source.aid || raw.aid || defaults.aid; const cid = source.cid || raw.cid || defaults.cid || source.pages?.[0]?.cid; const ep_id = source.ep_id || source.epid || raw.ep_id || raw.epid || defaults.ep_id; if(!cid && !ep_id && !bvid && !aid) return null; const title = source.part || source.title || source.show_title || source.arc?.title || raw.part || raw.title || defaults.title || `选集 ${index + 1}`; return {bvid,aid,cid,ep_id,title:String(title).trim() || `选集 ${index + 1}`}; }; const unique = (items) => { const seen = new Set(); return items.filter(item=>{ const key = item.cid ? `cid:${item.cid}` : item.ep_id ? `ep:${item.ep_id}` : `${item.bvid || ''}:${item.aid || ''}`; if(seen.has(key)) return false; seen.add(key); return true; }); }; const domEpisodes = Array.from(document.querySelectorAll( '.bpx-player-ctrl-eplist-multi-menu-item[data-cid], .bilibili-player-video-sections-item[data-cid], .bilibili-player-video-section-list-item[data-cid]' )).map((node,index)=>normalize({ cid:node.dataset.cid, title:node.querySelector('.bpx-player-ctrl-eplist-multi-menu-item-text')?.textContent?.trim() || node.textContent?.trim() },index,current)).filter(Boolean); const domItems = unique(domEpisodes); if(domItems.length) { return {label:`播放器选集(${domItems.length}集)`,items:domItems}; } const collection = state.ugc_season || state.ugcSeason || videoData.ugc_season || videoData.ugcSeason || this.getInfo('ugc_season') || this.window.ugc_season; const collectionRaw = []; if(Array.isArray(collection?.sections)) { collection.sections.forEach(section=>{ if(Array.isArray(section?.episodes)) collectionRaw.push(...section.episodes); }); } if(Array.isArray(collection?.episodes)) collectionRaw.push(...collection.episodes); const collectionItems = unique(collectionRaw.map((item,index)=>normalize(item,index,current)).filter(Boolean)); if(collectionItems.length) { return {label:`合集(${collectionItems.length}集)`,items:collectionItems}; } const pages = videoData.pages || state.pages || []; const pageItems = Array.isArray(pages) ? unique(pages.map((item,index)=>normalize(item,index,current)).filter(Boolean)) : []; if(pageItems.length) { return {label:`当前视频选集(${pageItems.length}集)`,items:pageItems}; } const seasonData = this.window.__NEXT_DATA__?.props?.pageProps?.dehydratedState?.queries ?.find(query=>query?.queryKey?.[0] == 'pgc/view/web/season')?.state?.data; const seasonEpisodes = (seasonData?.seasonInfo ?? seasonData)?.mediaInfo?.episodes || seasonData?.episodes || state.epList || []; const seasonItems = Array.isArray(seasonEpisodes) ? unique(seasonEpisodes.map((item,index)=>normalize(item,index,current)).filter(Boolean)) : []; if(seasonItems.length) { return {label:`合集/选集(${seasonItems.length}集)`,items:seasonItems}; } const currentItem = normalize(current,0,current); return {label:'当前视频',items:currentItem ? [currentItem] : []}; }, getBatchLanguageOptions(){ const result = [{value:'__auto__',label:'自动选择可用语言'}]; const seen = new Set(); (this.subtitle?.subtitles || []).forEach(item=>{ if(item.lan === 'close' || item.lan === 'local' || !item.lan || seen.has(item.lan)) return; seen.add(item.lan); result.push({value:item.lan,label:item.lan_doc || item.lan}); }); return result; }, async fetchBatchSubtitleConfig(item){ const params = []; if(item.cid) params.push(`cid=${encodeURIComponent(item.cid)}`); else if(item.ep_id) params.push(`ep_id=${encodeURIComponent(item.ep_id)}`); if(item.aid) params.push(`aid=${encodeURIComponent(item.aid)}`); else if(item.bvid) params.push(`bvid=${encodeURIComponent(item.bvid)}`); if(!params.length) throw '缺少视频参数'; const url = `https://api.bilibili.com/x/player${item.cid ? '/wbi' : ''}/v2?${params.join('&')}`; let ret = await fetch(url,{credentials:'include'}).then(res=>res.json()); let subtitle; if(ret.code === -404 && item.cid) { const dmParams = item.aid ? `aid=${encodeURIComponent(item.aid)}` : `bvid=${encodeURIComponent(item.bvid || '')}`; ret = await fetch(`https://api.bilibili.com/x/v2/dm/view?${dmParams}&oid=${encodeURIComponent(item.cid)}&type=1`,{credentials:'include'}).then(res=>res.json()); if(ret.code !== 0) throw ret.message || '无法读取字幕配置'; subtitle = ret.data?.subtitle; } else { if(ret.code !== 0 || !ret.data?.subtitle) throw ret.message || `读取字幕配置失败(${ret.code})`; subtitle = ret.data.subtitle; } const subtitles = (subtitle?.subtitles || []).filter(info=>info.lan !== 'close' && info.lan !== 'local' && info.subtitle_url); if(!subtitles.length) throw '该集没有可下载字幕'; return {subtitles}; }, async fetchBatchSubtitle(item,language){ const config = await this.fetchBatchSubtitleConfig(item); const info = language === '__auto__' ? config.subtitles[0] : config.subtitles.find(subtitle=>subtitle.lan === language); if(!info) throw `没有语言为“${language}”的字幕`; const subtitleUrl = new URL(String(info.subtitle_url).replace(/^https?:\/\//,'//'),location.href).href; // 字幕 CDN 通常不允许跨域携带凭据;这里保持和原单集下载一样使用默认凭据策略。 const response = await fetch(subtitleUrl); if(!response.ok && response.status !== 0) throw `字幕请求失败(${response.status})`; const data = await response.json(); if(!data || !Array.isArray(data.body)) throw '字幕数据格式错误'; return {data,language:info.lan_doc || info.lan}; }, encodeBatchSubtitle(data,type){ switch(String(type).toUpperCase()) { case 'ASS': return encoder.encodeToASS(data.body); case 'SRT': return encoder.encodeToSRT(data.body); case 'LRC': return encoder.encodeToLRC(data.body); case 'VTT': return encoder.encodeToVTT(data.body); case 'TXT': return data.body.map(item=>item.content).join('\r\n'); case 'BCC': return JSON.stringify(data,undefined,2); default: throw `不支持的格式:${type}`; } }, safeBatchName(name){ return String(name || '字幕').replace(/[\\/:*?"<>|]/g,'_').replace(/\s+/g,' ').trim().slice(0,100) || '字幕'; }, crc32(bytes){ let crc = 0xffffffff; for(const byte of bytes) { crc ^= byte; for(let bit=0;bit<8;bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); } return (crc ^ 0xffffffff) >>> 0; }, createSubtitleZip(entries){ const textEncoder = new TextEncoder(); const localParts = []; const centralParts = []; let offset = 0; const set16 = (view,position,value)=>view.setUint16(position,value,true); const set32 = (view,position,value)=>view.setUint32(position,value,true); entries.forEach(entry=>{ const nameBytes = textEncoder.encode(entry.name); const dataBytes = textEncoder.encode(entry.content); const crc = this.crc32(dataBytes); const local = new Uint8Array(30); const localView = new DataView(local.buffer); set32(localView,0,0x04034b50); set16(localView,4,20); set16(localView,6,0x800); set16(localView,8,0); set16(localView,10,0); set16(localView,12,0); set32(localView,14,crc); set32(localView,18,dataBytes.length); set32(localView,22,dataBytes.length); set16(localView,26,nameBytes.length); set16(localView,28,0); localParts.push(local,nameBytes,dataBytes); const central = new Uint8Array(46); const centralView = new DataView(central.buffer); set32(centralView,0,0x02014b50); set16(centralView,4,20); set16(centralView,6,20); set16(centralView,8,0x800); set16(centralView,10,0); set16(centralView,12,0); set16(centralView,14,0); set32(centralView,16,crc); set32(centralView,20,dataBytes.length); set32(centralView,24,dataBytes.length); set16(centralView,28,nameBytes.length); set16(centralView,30,0); set16(centralView,32,0); set16(centralView,34,0); set16(centralView,36,0); set32(centralView,38,0); set32(centralView,42,offset); centralParts.push(central,nameBytes); offset += local.length + nameBytes.length + dataBytes.length; }); const centralOffset = offset; const centralSize = centralParts.reduce((total,part)=>total + part.length,0); const end = new Uint8Array(22); const endView = new DataView(end.buffer); set32(endView,0,0x06054b50); set16(endView,4,0); set16(endView,6,0); set16(endView,8,entries.length); set16(endView,10,entries.length); set32(endView,12,centralSize); set32(endView,16,centralOffset); set16(endView,20,0); return new Blob([...localParts,...centralParts,end],{type:'application/zip'}); }, downloadBatchBlob(blob,name){ const url = URL.createObjectURL(blob); const link = elements.createAs('a',{href:url,download:name,style:'display:none;'},document.body); link.click(); setTimeout(()=>{ URL.revokeObjectURL(url); link.remove(); },15000); }, async startBatchDownload(items,language,type,status,startButton){ if(startButton.dataset.busy === '1') return; startButton.dataset.busy = '1'; startButton.style.opacity = '0.65'; startButton.style.pointerEvents = 'none'; const entries = []; const failed = []; for(let index=0;index 2 ? ';…' : ''})` : `完成:${entries.length} 集,已下载 ZIP 文件`; if(entries.length) encoder.showToast(`✅ 批量字幕已打包:${entries.length} 集`); else encoder.showToast('❌ 没有成功获取字幕','error'); }, createBatchDialog(items,sourceLabel){ const oldDialog = document.getElementById('cc-batch-dialog'); oldDialog && oldDialog.remove(); const overlay = elements.createAs('div',{ id:'cc-batch-dialog', style:'position:fixed;inset:0;background:transparent;pointer-events:none;z-index:1048576;' },document.body); const panel = elements.createAs('div',{ style:'position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:680px;max-width:calc(100vw - 30px);box-sizing:border-box;padding:18px;background:#fff;border-radius:8px;box-shadow:0 4px 18px rgba(0,0,0,.2);pointer-events:auto;color:#18191c;font-size:14px;' },overlay); if(!document.getElementById('cc-batch-dialog-style')) { elements.createAs('style',{ id:'cc-batch-dialog-style', innerHTML:'@media (max-width:720px){.cc-batch-control-panel{grid-template-columns:auto minmax(0,1fr) auto minmax(0,1fr)!important}.cc-batch-control-panel>:nth-child(5){grid-column:1}.cc-batch-control-panel>:nth-child(6){grid-column:2}}' },document.head); } const header = elements.createAs('div',{ innerText:'批量下载字幕', style:'font-size:20px;line-height:26px;color:#00a1d6;font-weight:500;margin-bottom:12px;' },panel); elements.createAs('span',{ innerText:sourceLabel, style:'display:block;color:#99a2aa;font-size:12px;margin-bottom:10px;' },panel); const controlPanel = elements.createAs('div',{className:'cc-batch-control-panel',style:'display:grid;grid-template-columns:auto minmax(150px,1fr) auto minmax(180px,1.2fr) auto 90px;column-gap:10px;row-gap:8px;align-items:center;margin-bottom:10px;'},panel); elements.createAs('span',{innerText:'范围:'},controlPanel); const scopeSelect = elements.createAs('select',{ style:'height:30px;width:100%;min-width:0;box-sizing:border-box;', innerHTML:`` },controlPanel); elements.createAs('span',{innerText:'语言:'},controlPanel); const languageSelect = elements.createAs('select',{style:'height:30px;width:100%;min-width:0;box-sizing:border-box;'},controlPanel); this.getBatchLanguageOptions().forEach(option=>elements.createAs('option',{value:option.value,innerText:option.label},languageSelect)); elements.createAs('span',{innerText:'格式:'},controlPanel); const formatSelect = elements.createAs('select',{ style:'height:30px;width:90px;min-width:90px;box-sizing:border-box;', innerHTML:['ASS','SRT','LRC','VTT','TXT','BCC'].map(type=>``).join(''), value:localStorage.defaultSubtitleType || 'SRT' },controlPanel); const selectionHeader = elements.createAs('div',{style:'display:flex;align-items:center;border-bottom:1px solid #e5e9ef;padding:6px 4px;'},panel); const allToggle = elements.createAs('input',{type:'checkbox',checked:true},selectionHeader); elements.createAs('span',{innerText:'全选 / 取消全选',style:'margin-left:6px;'},selectionHeader); const list = elements.createAs('div',{style:'max-height:280px;overflow:auto;border:1px solid #e5e9ef;border-top:0;padding:4px 8px;'},panel); const checks = []; items.forEach((item,index)=>{ const row = elements.createAs('label',{style:'display:flex;align-items:center;gap:7px;min-height:30px;border-bottom:1px solid #f1f2f3;cursor:pointer;'},list); const checkbox = elements.createAs('input',{type:'checkbox',checked:true},row); checks.push(checkbox); checkbox.onchange = ()=>{ allToggle.checked = checks.every(check=>check.checked); }; elements.createAs('span',{innerText:`${index + 1}. ${item.title}`,style:'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'},row); }); allToggle.onchange = ()=>checks.forEach(check=>check.checked = allToggle.checked); const status = elements.createAs('div',{innerText:'选择范围、语言和格式后,点击开始批量下载。文件会打包为 ZIP。',style:'color:#99a2aa;font-size:12px;line-height:20px;min-height:20px;margin-top:10px;'},panel); const actions = elements.createAs('div',{style:'display:flex;justify-content:flex-end;gap:8px;margin-top:10px;'},panel); const closeButton = elements.createAs('a',{innerText:'关闭',style:'height:24px;background:#99a2aa;color:#fff;padding:7px 14px;cursor:pointer;border-radius:2px;',onclick:()=>overlay.remove()},actions); const startButton = elements.createAs('a',{innerText:'开始批量下载',style:'height:24px;background:#00a1d6;color:#fff;padding:7px 14px;cursor:pointer;border-radius:2px;'},actions); startButton.onclick = () => { const selected = scopeSelect.value === 'all' ? items : items.filter((item,index)=>checks[index].checked); if(!selected.length){ status.innerText = '请至少勾选一集。'; return; } this.startBatchDownload(selected,languageSelect.value,formatSelect.value,status,startButton); }; }, openBatchDialog(){ this.setupData().then(subtitle=>{ if(!subtitle) throw '当前页面还没有读取到视频信息'; const batch = this.getBatchItems(); if(!batch.items.length) throw '没有找到可批量处理的合集或选集'; this.createBatchDialog(batch.items,batch.label); }).catch(error=>{ console.error('打开批量下载窗口失败',error); if(typeof encoder !== 'undefined' && encoder.showToast) { encoder.showToast(`❌ 打开批量下载失败:${error}`,'error'); } else { this.toast('打开批量下载窗口失败',error); } }); }, openDownloadDialog(){ this.setupData().then(subtitle=>{ const languages = (subtitle?.subtitles || []).filter(item=>item.lan !== 'close' && item.lan !== 'local'); if(!languages.length) throw '当前视频没有可用的在线字幕'; const lan = encoder.currentLan && languages.some(item=>item.lan === encoder.currentLan) ? encoder.currentLan : languages[0].lan; return this.getSubtitle(lan).then(data=>encoder.showDialog(data,false,lan)); }).catch(e=>this.toast('打开字幕窗口失败',e)); }, toast(msg,error){ if(error) console.error(msg,error); if(!this.toastDiv){ this.toastDiv = document.createElement('div'); this.toastDiv.className = 'bilibili-player-video-toast-item'; } const panel = elements.getAs('.bilibili-player-video-toast-top'); if(!panel) return; clearTimeout(this.removeTimmer); this.toastDiv.innerText = msg + (error?`:${error}`:''); panel.appendChild(this.toastDiv); this.removeTimmer = setTimeout(()=>{ panel.contains(this.toastDiv)&&panel.removeChild(this.toastDiv) },3000); }, async updateLocal(data){ this.datas.local = data; return this.updateSubtitle(data); }, async updateSubtitle(data){ this.window.player.updateSubtitle(data); }, loadSubtitle(lan){ this.getSubtitle(lan) .catch(()=>this.setupData(true)).then(()=>this.getSubtitle(lan)) .then(data=>this.updateSubtitle(data)) .then(()=>this.toast(lan=='close'?'字幕已关闭':`载入字幕:${this.getSubtitleInfo(lan).lan_doc}`)) .catch(e=>this.toast('载入字幕失败',e)); }, downloadSubtitle(lan, name, direct){ this.getSubtitle(lan,name) .catch(()=>this.setupData(true)).then(()=>this.getSubtitle(lan, name)) .then(data=>{ const item = this.getSubtitleInfo(lan,name); return encoder.showDialog(data,direct,item && item.lan); }).catch(e=>bilibiliCCHelper.toast('获取字幕失败',e)); }, async getSubtitle(lan, name){ if(this.datas[lan]) return this.datas[lan]; const item = this.getSubtitleInfo(lan, name); if(!item) throw('找不到所选语言字幕'+lan); if(this.datas[item.lan]) return this.datas[item.lan]; return fetch(item.subtitle_url) .then(res=>res.json()) .then(data=>(this.datas[item.lan] = data)); }, getSubtitleInfo(lan, name){ return this.subtitle.subtitles.find(item=>item.lan==lan || item.lan_doc==name); }, getInfo(name) { return this.window[name] || this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__[name] || this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__.epInfo && this.window.__INITIAL_STATE__.epInfo[name] || this.window.__INITIAL_STATE__ && this.window.__INITIAL_STATE__.videoData && this.window.__INITIAL_STATE__.videoData[name]; }, getEpid(){ return this.getInfo('id') || /ep(\d+)/.test(location.pathname) && +RegExp.$1 || /ss\d+/.test(location.pathname); }, getEpInfo(){ const bvid = this.getInfo('bvid'), epid = this.getEpid(), cidMap = this.getInfo('cidMap'), page = this?.window?.__INITIAL_STATE__?.p; let ep = cidMap?.[bvid]; if (ep) { this.aid = ep.aid; this.bvid = ep.bvid; this.cid = ep.cids[page]; return this.cid; } ep = this.window.__NEXT_DATA__?.props?.pageProps?.dehydratedState?.queries ?.find(query=>query?.queryKey?.[0] == "pgc/view/web/season") ?.state?.data; ep = (ep?.seasonInfo??ep)?.mediaInfo?.episodes ?.find(ep=>epid == true || ep.ep_id == epid); if (ep) { this.epid = ep.ep_id; this.cid = ep.cid; this.aid = ep.aid; this.bvid = ep.bvid; return this.cid; } ep = this.window.__INITIAL_STATE__?.epInfo; if (ep){ this.epid = ep.id; this.cid = ep.cid; this.aid = ep.aid; this.bvid = ep.bvid; return this.cid; } ep = this.window.playerRaw?.getManifest(); if (ep){ this.epid = ep.episodeId; this.cid = ep.cid; this.aid = ep.aid; this.bvid = ep.bvid; return this.cid; } }, async setupData(force){ if(this.subtitle && (this.pcid == this.getEpInfo()) && !force) return this.subtitle; if(location.pathname=='/blackboard/html5player.html') { let match = location.search.match(/cid=(\d+)/i); if(!match) return; this.window.cid = match[1]; match = location.search.match(/aid=(\d+)/i); if(match) this.window.aid = match[1]; match = location.search.match(/bvid=(\d+)/i); if(match) this.window.bvid = match[1]; } this.pcid = this.getEpInfo(); if((!this.cid&&!this.epid)||(!this.aid&&!this.bvid)) return; this.player = this.window.player; this.subtitle = {count:0,subtitles:[{lan:'close',lan_doc:'关闭'},{lan:'local',lan_doc:'本地字幕'}]}; if (!force) this.datas = {close:{body:[]},local:{body:[]}}; decoder.data = undefined; 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=>{ if (res.status==200) { return res.json().then(ret=>{ if (ret.code == -404) { 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=>{ return res.json() }).then(ret=>{ if (ret.code!=0) throw('无法读取本视频APP字幕配置'+ret.message); this.subtitle = ret.data && ret.data.subtitle || {subtitles:[]}; this.subtitle.count = this.subtitle.subtitles.length; this.subtitle.subtitles.forEach(item=>(item.subtitle_url = item.subtitle_url.replace(/https?:\/\//,'//'))) this.subtitle.subtitles.push({lan:'close',lan_doc:'关闭'},{lan:'local',lan_doc:'本地字幕'}); this.subtitle.allow_submit = false; return this.subtitle; }); } if(ret.code!=0||!ret.data||!ret.data.subtitle) throw('读取视频字幕配置错误:'+ret.code+ret.message); this.subtitle = ret.data.subtitle; this.subtitle.count = this.subtitle.subtitles.length; this.subtitle.subtitles.push({lan:'close',lan_doc:'关闭'},{lan:'local',lan_doc:'本地字幕'}); return this.subtitle; }); } else { throw('请求字幕配置失败:'+res.statusText); } }) }, tryInit(){ this.setupData().then(subtitle=>{ if(!subtitle) return; if(elements.getAs('#bilibili-player-subtitle-btn')) { console.log('CC助手已初始化'); } else if(elements.getAs('.bilibili-player-video-btn-color')){ oldPlayerHelper.init(subtitle); } else if(elements.getAs('.bilibili-player-video-danmaku-setting')){ player2x.init(subtitle); } else if (elements.getAs('.bpx-player-ctrl-subtitle-major-content')){ player315.init(subtitle); } else if(elements.getAs('.squirtle-subtitle-wrap')){ player314.init(subtitle); } else { console.log('bilibili cc未发现可识别版本播放器') } }).catch(e=>{ this.toast('CC字幕助手配置失败',e); }); }, init(){ this.registerMenuCommands(); this.createFloatingButton(); this.tryInit(); new MutationObserver((mutations, observer)=>{ for (const mutation of mutations){ if(!mutation.target) return; if(mutation.target.getAttribute('stage')==1 || mutation.target.classList.contains('bpx-player-subtitle-wrap') || mutation.target.classList.contains('tit') || mutation.target.classList.contains('bpx-player-ctrl-subtitle-bilingual') || mutation.target.classList.contains('squirtle-quality-wrap')){ this.tryInit(); break; } } }).observe(document.body,{ childList: true, subtree: true, }); } }; bilibiliCCHelper.init(); })();