v5 2024年12月7日
- 修理B站 new api,感谢 wxupjack
- 修理youtube trusted html policy
- 增加url匹配支持youtube unblocked在线代理
脚本支持热更新调试,多次执行保留最后一次结果。
TODO
- 高大上的设置界面
- 生成TTS有声文本
- 解除dock,悬浮窗模式
v5 2024年12月7日
脚本支持热更新调试,多次执行保留最后一次结果。
TODO
// ==UserScript== | |
// @name B站学习机 | Bilibili+Youtube字幕全文阅读 | coursera-like subtitles fulltext reader | |
// @namespace https://gist.github.com/KnIfER/9e43ffa31c3b9831a500edf35595c1dc | |
// @version 6 | |
// @description 在线字幕阅读或下载,B站油管秒变cousera! - Read & learn subtitles full text online! | |
// @author KnIfER | |
// @match https://*.bilibili.com/video/* | |
// @match https://*.youtube.com/* | |
// @match https://*/watch?v=* | |
// @match https://*/embed/*?si=* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=bilibili.com | |
// @require file:///D:\Code\FigureOut\chrome\extesions\TamperMonkey\B站学习机.user.js | |
// @license MIT | |
// ==/UserScript== | |
// (function() { | |
var trustedPolicy = trustedTypes.createPolicy("myPolicy", { | |
createHTML: (s) => s | |
}); | |
'use strict'; | |
var lastVid='x'; | |
var win = window.unsafeWindow || window, doc=document, d=doc | |
, bank=win.parent._xxj_bank | |
, isBY = location.host.indexOf('bilibili')>=0?0:1 | |
, isYProxy = e=>parent.advancedVideoPlayer && parent.advancedVideoPlayerContainer // YOUTUBEUNBLOCKED | |
, Data; | |
if(!bank) { | |
bank = win.parent._xxj_bank = {}; | |
} else try{ | |
lastVid = bank.unreg(); | |
} catch(e) {} | |
bank.unreg = uninstall; | |
// unregister the script for hot reload | |
var unregs = []; | |
function uninstall() { | |
if(Btn) Btn.remove(); | |
if(TextPane) TextPane.remove(); | |
// | |
Btn = 0; | |
Menu = 0; | |
TextPane = 0; | |
// | |
if(isBY==0) { | |
proto.open = proto.realOpen; | |
proto.send = proto.realSend; | |
} | |
var tmp = ge('_xxj_sty'); | |
if(tmp) tmp.remove(); | |
for(var i=0;i<unregs.length;i++) { | |
unregs[i](); | |
} | |
return lastVid; | |
} | |
function debug(a,b,c,d,e){var t=[a,b,c,d,e];for(var i=5;i>=0;i--){if(t[i]===undefined)t[i]='';else break}console.log("%c 学习机 ","color:#eee!important;background:#0FF;",t[0],t[1],t[2],t[3],t[4])} | |
function gc(c, d) { | |
return (d||document).getElementsByClassName(c)[0]; | |
} | |
function gt(c, d) { | |
return (d||document).getElementsByTagName(c)[0]; | |
} | |
function ge(id) { | |
return document.getElementById(id); | |
} | |
function gcs(c, d) { | |
return (d||document).getElementsByClassName(c); | |
} | |
function gts(c, d) { | |
return (d||document).getElementsByTagName(c); | |
} | |
function gcp(c, d, max) { | |
var p = d||document; | |
if(!max) max=99999; | |
while(p) { | |
if(p.classList && p.classList.contains(c)) return p; | |
p = p.parentNode; | |
if(--max<=0) return null; | |
} | |
return p; | |
} | |
function addEvent(a, b, c, d) { | |
if(!d) d = win; | |
d.addEventListener(a, b, c); | |
unregs.push(function(){ d.removeEventListener(a, b, c)} ); | |
} | |
function delEvent(a, b, c, d) { | |
if(!d) d = win; | |
d.removeEventListener(a, b, c); | |
} | |
function stop(e) { | |
try{ | |
e.stopPropagation(); | |
e.preventDefault(); | |
} catch(e) {debug(e)} | |
} | |
function editing() { | |
var act = document.activeElement; | |
if(act) | |
return act.tagName==='INPUT' | |
|| act.contentEditable==='true' | |
|| act.tagName==='TEXTAREA' | |
} | |
var proto = XMLHttpRequest.prototype, html=e=>e; | |
if(isBY==0) { | |
proto.realOpen = proto.open; | |
proto.open = function(method, url, a, u, p) { | |
//debug('request::open!!!', url); | |
this.realOpen(method, url , true, u, p); // set async to true to avoid 'sync responseType error' | |
if(url) { | |
var tmp = new RegExp('(aid=[0-9]+&cid=[0-9]+)').exec(url); | |
if(tmp) tmp = tmp[0]; | |
if(tmp && lastVid!=tmp) { | |
lastVid = tmp; | |
debug('正在播放='+lastVid); | |
} | |
} | |
}; | |
proto.realSend = proto.send; | |
proto.send = function(b) { | |
//debug('request::send!!!', b); | |
this.realSend(b); | |
}; | |
} else { | |
html=e=>trustedPolicy.createHTML(e); | |
} | |
// 动态z-order,配合B站笔记窗口 | |
var zIndexes = ['1500', '10000']; | |
if(isBY==1) { | |
zIndexes = ['2030', '10000']; | |
} | |
var loadOnStart = false; /* true false 是否自动分析字幕 */ | |
var autoFTM = false; /* true false 是否自动打开字幕列表 */ | |
// the panel, textview, and the button | |
var TextPane, tv, Btn, installTryCnt=0 | |
, autoScroling, userScrollTm=0 | |
, moved, focused=0 | |
// the menu | |
, Menu, MenuSty | |
// video tag | |
, Vid | |
; | |
function ge(e,p){return (p||doc).getElementById(e)}; | |
function gc(e,p){return (p||doc).getElementsByClassName(e)[0]}; | |
function craft(p, t, c) { | |
var e=doc.createElement(t||'DIV'); | |
if(c)e.className=c; | |
if(p)p.appendChild(e); | |
return e; | |
} | |
function installBtn(){ | |
if(!Btn || !Btn.parentNode){ | |
var ibf = 0, tmp; | |
if(isBY==0) { | |
ibf = doc.getElementsByClassName("bpx-player-ctrl-subtitle")[0]; | |
if(!ibf) ibf = doc.getElementsByClassName("bpx-player-ctrl-volume")[0]; | |
if(ibf) ibf = ibf.nextElementSibling; | |
} else { | |
ibf = doc.getElementsByClassName("ytp-settings-button")[0]; | |
if(!ibf) { | |
tmp = doc.getElementsByClassName("slim_video_action_bar_renderer_button"); | |
ibf = tmp[tmp.length-1]; | |
} | |
} | |
debug('insertBefore', ibf, installTryCnt); | |
if(ibf) { | |
// insert a control BUTTON | |
tmp = craft(doc.head, "STYLE"); | |
tmp.id = "_xxj_sty" | |
tmp.textContent = ".ytp-gradient-top,.ytp-chrome-top{opacity:0}.ytp-fulltext-menu{right: 12px;bottom: 53px;z-index: 71;will-change: width,height;}._xxj_menu .ytp-menuitem-label{width:65%;}._xxj_menu{user-select:none}"; | |
if(isBY==0) { | |
tmp.textContent+=".ytp-menuitem>div{display:inline-block;font-size:medium}.ytp-menuitem-label{cursor:pointer}"; | |
} | |
if(isBY==1) { | |
tmp.textContent+="._xxj_menu .ytp-menuitem-label{width:65%;white-space:nowrap;font-size:100%;}._xxj_menu .ytp-menuitem-content{white-space:nowrap;font-size:100%;}"; | |
} | |
tmp = craft(0, isBY==1?'BUTTON':'DIV', "ytp-fulltext-button ytp-button bpx-player-ctrl-btn"); | |
tmp.id = "_xxj_btn" | |
tmp.title="字幕学习机 (x)"; | |
// button svg icon | |
debug('create the control button') | |
tmp.innerHTML = html('<svg height="100%" version="1.1" viewBox="0 0 36 36" width="100%"><g class="ytp-fullscreen-button-corner-0"><use class="ytp-svg-shadow" xlink:href="#ytp-id-99"></use><path class="ytp-svg-fill" d="M18.97,18h6.82v1.46h-6.82zM18.97,15.57h6.82L25.8,17.03h-6.82zM18.97,20.43h6.82L25.8,21.89h-6.82zM26.77,10.19L9.23,10.19c-1.07,0 -1.96,0.88 -1.96,1.96v12.67c0,1.07 0.88,1.96 1.96,1.96h17.55c1.07,0 1.96,-0.88 1.96,-1.96L28.73,12.15c0,-1.07 -0.88,-1.96 -1.96,-1.96zM26.77,24.83h-8.77L18,12.15h8.77v12.67z" id="ytp-id-99"></path></g></svg>' | |
); | |
tmp.onclick = function() { | |
if(MenuSty) { | |
tmp = MenuSty; | |
if(tmp.display!="none") { | |
tmp.display="none" | |
} else { | |
tmp.display=""; | |
build_cc_menu() | |
} | |
} else { | |
build_cc_menu() | |
} | |
} | |
var ts = tmp.style; | |
if(isBY==0) { | |
ts.maxHeight='30px' | |
tmp.firstElementChild.style = "transform:scale(1.5);" | |
} | |
if(isBY==1) { | |
if(location.host[0]=='m') { | |
ts.marginTop='.5%'; | |
ts.minWidth='25px'; | |
gc('ytp-svg-fill', tmp).style.fill='#000'; | |
} | |
} | |
ibf.parentNode.insertBefore(tmp, ibf); | |
Btn=tmp; | |
debug('成功安装按钮:', tmp); | |
// if(autoFTM) { | |
// build_cc_menu() | |
// } | |
// if(loadOnStart) { | |
// // todo load initial lyrics | |
// build_cc_menu(1); | |
// initYFT(); | |
// } | |
} else if(installTryCnt++<15){ | |
setTimeout(installBtn, 500); | |
} | |
} | |
} | |
function tvShown(){ | |
return TextPane && TextPane.style.display!='none'; | |
} | |
var keysDwn=[]; | |
let pdoc = doc; | |
if(isYProxy()) { | |
pdoc = parent.document; | |
} | |
function fnKeydown(e){ | |
//debug('fnKeydown', tvShown(), e.code, e.code==="KeyX", e.altKey) | |
if(!keysDwn[e.code]) { | |
keysDwn[e.code] = e; | |
if(!editing()) | |
if(focused || tvShown()) { | |
if(focused) { | |
if(e.code==="Escape") { | |
TextPane.close(); | |
stop(e); | |
} | |
} | |
// if(userScrollTm && e.code==="ArrowRight" && e.code==="ArrowLeft") { | |
// userScrollTm = 0; | |
// debug('userScrollTm = 0'); | |
// } | |
if(e.code==="KeyX"/* && e.altKey */) { | |
TextPane.close(); | |
} | |
} | |
else if(e.code==="KeyX"/* && e.altKey */) { | |
installTextPane().style.display = ""; | |
} | |
} | |
} | |
function fnKeyup(e){ | |
delete keysDwn[e.code]; | |
} | |
addEvent("keydown", fnKeydown, 1, pdoc); | |
addEvent("keyup", fnKeyup, 1, pdoc); | |
if(pdoc!=doc) { | |
addEvent("keydown", fnKeydown, 1, doc); | |
addEvent("keyup", fnKeyup, 1, doc); | |
} | |
function insertTextToEl(){ | |
if(isBY==0) { | |
return gc('bpx-player-container'); | |
} else { | |
return isYProxy()&&parent['player-container-inner'] | |
} | |
} | |
function installTextPane(H){ | |
if(!TextPane) { | |
craft(pdoc.head, "STYLE").innerHTML = html("a.ft-time:before{content:attr(data-val)}a.ft-time{text-decoration:none;color:blue;user-select:none;-moz-user-select:none}._xxj_ft_ln.curr {border-bottom: 5px solid #0000ffac;}ytd-masthead{background: transparent;}._xxj_btn:hover{ box-shadow: 1px 1px 2px 1px rgb(0 0 0 / 15%); }._xxj_btn:active{ box-shadow: inset 1px 1px 2px 1px rgb(0 0 0 / 15%);}" | |
+ ".bpx-player-container[data-screen=full], .bpx-player-container[data-screen=web] {z-index: 1500!important;}" | |
+ "#bilibili-player.mode-webscreen {z-index: 1500!important;}" | |
// + "._xxj_tv {display: none;}" | |
); | |
if(isYProxy() && win==parent) { | |
return; | |
} | |
// the lyrics display float window. | |
var advPlayer = insertTextToEl(); | |
TextPane=craft(advPlayer||pdoc.body,0,"_xxj_tv"); | |
TextPane.innerHTML=html('<p class="drag_resizer"></p><div class="_xxj_tvp"><p class="_xxj_ftv">FETCHING……</p></div>'); | |
tv = gc('_xxj_ftv', TextPane); | |
tv.style = 'margin-left:5px;font-size:x-large;padding:9px 100px 0 100px;'; | |
var tvP = gc('_xxj_tvp', TextPane) | |
, tvPs = TextPane.style | |
, x = 0 | |
, minHeight = 1.35 * (parseInt(getComputedStyle(tv).lineHeight)||tv.offsetHeight); | |
; | |
tvPs.zIndex = zIndexes[1]; | |
tvP.style = 'overflow-y:scroll;height:100%;'; | |
TextPane.style='position:fixed;bottom:0;left:0;width:100%;height:'+minHeight+'px;box-sizing:border-box;background:#fff;z-index:10000;overflow:hidden;transition:background 0.25s'; | |
// the play button. | |
var playBtn = craft(TextPane); | |
playBtn.style = 'position:fixed;height:70px;width:80px;bottom:0;'; | |
playBtn.innerHTML = html('<svg style="background-color:#fff;fill: #03a9f4ab;width: 60px;border-radius: 4px;" class="_xxj_btn" height="100%" version="1.1" viewBox="0 0 36 36" width="100%"><g><path class="btnPlay" d="M12,7.5v21l16.5,-10.5z"></path><path class="btnPause" d="M9,7.5h6v21L9,28.5zM21,7.5h6v21h-6z"></path></g></svg>'); | |
var btnPause = gc('btnPause', playBtn), btnPlay = gc('btnPlay', playBtn); | |
function syncPlay(p) { | |
btnPause.style.display=p?'':'none'; | |
btnPlay.style.display=p?'none':''; | |
playBtn.title = p?'暂停':'播放'; | |
} | |
playBtn = playBtn.children[0]; | |
function togglePlay(){ | |
var p = Vid.playing; | |
if(p) Vid.pause(); | |
else Vid.play(); | |
syncPlay(Vid.playing); | |
} | |
addEvent('click', togglePlay, true, playBtn) | |
addEvent('contextmenu', playBtn.ctx = function(e) { | |
lcN=userScrollTm=0; | |
timeUpdate(); | |
stop(e); | |
}, true, playBtn) | |
// | |
// float window control buttons on the top-right corner. | |
// | |
var topBtns = craft(TextPane); | |
topBtns.style='user-select:none;padding-right:0.25em;font-weight:600;text-decoration:none;position:absolute;top:0;right:20px;font-size:17px;'; | |
// the close button. | |
var closeBtn = craft(topBtns, 'A', 'notranslate'); | |
closeBtn.innerText = '[X]'; | |
closeBtn.style = 'color:#175199;'; | |
closeBtn.title = '关闭'; | |
closeBtn.onclick = TextPane.close = function(){ | |
tvPs.display = 'none'; | |
focused = 0; | |
} | |
// the maximise button. | |
craft(topBtns, 'DIV').style='height:5px;'; | |
var maxBtn = craft(topBtns, 'A', 'notranslate'); | |
maxBtn.innerText = '[▢]'; | |
maxBtn.style = 'color:#175199;'; | |
maxBtn.title = '最大化'; | |
maxBtn.onclick = function(){ | |
} | |
// the opacity button. | |
craft(topBtns, 'DIV').style='height:5px;'; | |
var zenBtn = craft(topBtns, 'A', 'notranslate'); | |
zenBtn.innerText = '[⊥]'; | |
zenBtn.style = 'color:#175199;transform:rotate(180deg);position:absolute;'; | |
zenBtn.title = '透明背景'; | |
zenBtn.onclick = function(){ | |
if(tvPs.background=='rgb(255, 255, 255)') | |
tvPs.background = 'rgb(255, 255, 255, 0.55)' | |
else | |
tvPs.background = 'rgb(255, 255, 255)' | |
debug(tvPs.background); | |
} | |
// drag-resize the TextView, bindResize | |
if(1) { | |
var el = gc('drag_resizer', TextPane); | |
el.style = 'position:absolute;top:0;right:0;height:6px;width:100%;padding:0;cursor:ns-resize'; | |
function y(e){ | |
if(e.clientY==undefined) | |
return e.originalEvent.changedTouches[0].clientY; | |
return e.clientY; | |
} | |
// drag-resie area on the top | |
function mousedown(e){ | |
x = y(e) + tvP.offsetHeight; | |
stop(e); | |
addEvent("mousemove", mouseMove, 1, pdoc); | |
addEvent("mouseup", mouseUp, 1, pdoc); | |
}; | |
function mouseMove(e){ | |
var h = x - y(e); | |
tvPs.height = Math.min(pdoc.documentElement.clientHeight, Math.max(minHeight, h)) + 'px'; | |
} | |
function mouseUp(){ | |
delEvent("mousemove", mouseMove, 1, pdoc); | |
delEvent("mouseup", mouseUp, 1, pdoc); | |
} | |
el.addEventListener("mousedown", mousedown); | |
el.addEventListener("touchstart", mousedown); | |
el.addEventListener("touchmove", mouseMove); | |
el.addEventListener("touchend", mouseUp); | |
// 右击拖拽缩放 | |
function fnDown(e){ | |
// debug("mousedown", e.target); | |
if(tvShown()) { | |
var p=e.path,t=e.target,d=!!gcp('_xxj_tv', t,5); | |
if(gcp('_xxj_btn',t,5)) { | |
playBtn.ctx(e); | |
} | |
else if(d && e.button==2) { | |
debug('开启移动检测') | |
moved = 7; | |
delEvent("mousemove", fnMove, 1, pdoc); | |
//pdoc.addEventListener("mousemove", fnMove) | |
setTimeout(function(){addEvent("mousemove", fnMove, 1, pdoc)}, 64); | |
} | |
if(d ^ focused) { | |
focused = d; | |
tvPs.zIndex = zIndexes[d]; | |
if(!d && userScrollTm) { | |
userScrollTm = 0; | |
} | |
} | |
} | |
} | |
function fnMenu(e){ | |
// debug('contextmenu', moved, e.target); | |
if(moved==-1) { | |
stop(e); | |
} | |
else if(focused && e.target.tagName!=='A') { | |
delEvent("mousemove", fnMove, 1, pdoc); | |
if(window.getSelection().isCollapsed) { | |
debug('该显示特别菜单啊!'); | |
fnAbort(); | |
moved = 0; | |
} | |
} | |
} | |
addEvent("contextmenu", fnMenu, 1, pdoc); | |
addEvent("pointerdown", fnDown, 1, pdoc); | |
function fnAbort(){ | |
debug('fnAbort'); | |
moved=-1; | |
delEvent("mousemove", fnMove, 1, pdoc); | |
delEvent("mouseup", fnAbort, 1, pdoc); | |
} | |
function fnMove(e){ | |
//debug('fnMove', e); | |
if(moved==7) { | |
debug('开始右击手势移动', e); | |
moved = 1; | |
x = y(e) + tvP.offsetHeight; | |
delEvent("mouseup", fnAbort, 1, pdoc); addEvent("mouseup", fnAbort, 1, pdoc); | |
} | |
if(moved==1) { | |
mouseMove(e); | |
} | |
} | |
tvP.addEventListener("scroll", function(e){ | |
if(autoScroling) { | |
var tmp=Math.ceil(autoScroling), now=tvP.scrollTop; | |
if(now>=tmp-1 && now<=tmp+1) { | |
return; | |
} | |
autoScroling = 0; | |
} | |
//debug('scroll!', autoScroling, tvP.scrollTop); | |
userScrollTm = Date.now(); | |
}); | |
tvP.addEventListener("click", function(e){ | |
if(e.target.className==="ft-time") { | |
stop(e); | |
Vid.currentTime=parseFloat(e.target.getAttribute("data-tm")); | |
if(!Vid.playing) { | |
Vid.play(); | |
} | |
var n = e.target.nextElementSibling; | |
if(n && n.classList.contains('_xxj_ft_ln')) { | |
if(lcE) { | |
lcE.classList.remove("curr"); | |
} | |
lcE = n; | |
n.classList.add("curr"); | |
} | |
} | |
}); | |
TextPane.ondblclickx = function(e) { | |
debug(e, getSelection().isCollapsed); | |
if((e.target==tv || e.target==tvP) | |
&& (e.offsetX<95 || e.offsetX>tvP.clientWidth+100)) { | |
togglePlay(); | |
getSelection().empty(); | |
stop(e); | |
} | |
} | |
TextPane.addEventListener('dblclick', TextPane.ondblclickx, 1) | |
} | |
function timeUpdate(e) { | |
// lyrics scroll sync to time | |
var tm=Vid.currentTime; | |
if(lrcArr && (!lcN||tm>=lcN.endTime||tm<lcN.startTime)) { | |
var n = reduce(tm,lrcArr,0,lrcArr.length); | |
if(n && n!=lcN) { | |
lcN = n; | |
if(lcE) { | |
lcE.classList.remove("curr"); | |
} | |
n = n.ele; | |
lcE = n; | |
if(n) { | |
n.classList.add("curr"); | |
} | |
if(userScrollTm) { | |
var scrollWait = 800; | |
if(Date.now()-userScrollTm > scrollWait) { | |
userScrollTm = 0; | |
} | |
} | |
if(window.getSelection().isCollapsed | |
&& userScrollTm==0 && moved!=1 | |
&& (n.offsetTop+n.offsetHeight+minHeight/2>tvP.scrollTop+tvP.offsetHeight | |
||n.offsetTop<tvP.scrollTop)) { | |
autoScroling=n.offsetTop; | |
if(tvP.offsetHeight > minHeight*1.7) { | |
autoScroling -= minHeight/2; | |
} | |
// 自动滚动 | |
tvP.scrollTop=autoScroling; | |
// tvP.scrollTo({ // todo 平滑滚动 | |
// top: autoScroling | |
// ,behavior: 'smooth' | |
// }); | |
} | |
} | |
} | |
} | |
// install timers to h5 video tag | |
function installTimer() { | |
if(Vid==null) { | |
Vid=document.querySelector('video') | |
if(Vid==null) { | |
setTimeout(installTimer, 100) | |
} | |
else { | |
Vid.addEventListener('timeupdate', timeUpdate); | |
Vid.addEventListener('playing', e => { | |
syncPlay(1); | |
}); | |
Vid.addEventListener('play', e => { | |
syncPlay(1); | |
}); | |
Vid.addEventListener('pause', e => { | |
syncPlay(0); | |
}); | |
Vid.addEventListener('seeking', e => { | |
userScrollTm = 0; | |
timeUpdate(e); | |
//debug('seeking...', Vid.currentTime, e) | |
}); | |
if(Vid.playing==undefined) { | |
Object.defineProperty(HTMLMediaElement.prototype, 'playing', { | |
get: function(){ | |
return !!(this.currentTime > 0 && !this.paused && !this.ended && this.readyState > 2); | |
} | |
}) | |
} | |
syncPlay(Vid.playing); | |
} | |
} | |
} | |
installTimer(); | |
//var insertionLis = e => { | |
// //console.log("DOMNodeInserted") | |
// if(document.body.lastElementChild!=YFT){ | |
// document.body.removeChild(YFT); | |
// document.body.appendChild(YFT); | |
// } | |
//}; | |
//document.body.addEventListener('DOMNodeInserted', insertionLis) | |
} | |
// ensure visibility | |
if(H>0) { | |
var tmp = TextPane.style; | |
var h = parseFloat(tmp.height); | |
if(h!=h||h<H) { | |
tmp.height = H+"px" | |
} | |
if(tmp.display!=="") { | |
tmp.display = "" | |
} | |
} | |
focused = 1; | |
return TextPane; | |
} | |
addEvent('keydown', e=>{ | |
// de('keydown', e) | |
if(isYProxy()) | |
if(e.key=='Enter' && !editing() && !(e.altKey||e.shiftKey||e.ctrlKey)) { | |
if(doc.fullscreenElement||parent.doc.fullscreenElement) { | |
doc.exitFullscreen(); | |
parent.doc.exitFullscreen(); | |
stop(e) | |
return | |
} | |
// doc.body.requestFullscreen(); | |
parent['player-container-inner'].requestFullscreen(); | |
stop(e) | |
} | |
}, 1, doc); | |
/*via mdict-js*/ | |
function reduce(val,arr,st,ed) { | |
var len = ed-st; | |
if (len > 1) { | |
len = len >> 1; | |
return val > arr[st + len - 1].endTime | |
? reduce(val,arr,st+len,ed) | |
: reduce(val,arr,st,st+len); | |
} else { | |
return arr[st]; | |
} | |
} | |
// http://qtdebug.com/fe-srt/ | |
function parseSrt(srt) { | |
var parsed = []; | |
var textSubtitles = srt.split('\n\n'); // 每条字幕的信息,包含了序号,时间,字幕内容 | |
for (var i = 0; i < textSubtitles.length; ++i) { | |
var textSubtitle = textSubtitles[i].split('\n'); | |
if (textSubtitle.length >= 2) { | |
var sn = textSubtitle[0]; | |
var tms = textSubtitle[1].split(' --> '); | |
var startTime = toSeconds(tms[0]); | |
var endTime = toSeconds(tms[1]); | |
var content = textSubtitle[2]; | |
// 字幕可能有多行 | |
if (textSubtitle.length > 2) { | |
for (var j = 3; j < textSubtitle.length; j++) { | |
content += ' ' + textSubtitle[j]; | |
} | |
} | |
parsed.push({ | |
sn: sn, | |
startTime: startTime, | |
endTime: endTime, | |
content: content | |
}); | |
} | |
} | |
return parsed; | |
} | |
function toSeconds(t) { | |
var s = 0.0; | |
if (t) { | |
var p = t.trim().split(':'); | |
for (var i = 0; i < p.length; i++) { | |
s = s * 60 + parseFloat(p[i].replace(',', '.')); | |
} | |
} | |
return s; | |
} | |
var tracks = win._xxj_tracks = []; // store all subtitle tracks | |
var lrcArr; | |
var lcN, lcE; | |
function AppendFulltext(sub, d) { | |
debug("APFT", sub, d); | |
var lrc = sub.srt; | |
if(d) { | |
// var t=win.title; | |
// if(t)t=t.innerText; | |
var t=document.title; | |
downloadString(lrc, "text/plain", t+"."+(sub.lang_code||"a")+".srt"); | |
return; | |
} | |
win.srtlrc=sub; | |
// parse | |
var lrcs = parseSrt(lrc); | |
var span=""; | |
var lastTime=0; | |
// concate | |
for(var i=0;i<lrcs.length;i++){ | |
var lI=lrcs[i]; | |
var text = lI.content; | |
var lnSep="<br><br>"; | |
var sepLn=""; | |
if(lI.startTime-lastTime>3){ | |
var idx = text.indexOf("."); | |
// skip numberic dots | |
while(idx>0) { | |
if(idx+1>=text.length||text[idx+1]<=' ') { | |
break; | |
} | |
idx = text.indexOf(".", idx+1); | |
} | |
if(idx<0) idx = text.indexOf("。"); | |
if(idx<0) idx = text.indexOf(","); | |
if(idx<0) idx = text.indexOf(","); | |
if(idx>=0) { | |
text=" "+text.substring(0, idx+1) | |
+lnSep+text.substring(idx+1); | |
} else { | |
sepLn = lnSep; | |
} | |
lnSep = " "; | |
} else { | |
// merge to previous line | |
text=" "+text; | |
lnSep = ""; | |
} | |
//console.log(lI.startTime-lastTime); | |
var s = lI.startTime; | |
var m = parseInt(lI.startTime/60); | |
span+=sepLn+"<a class='ft-time' href='' data-val='" + " " | |
+(m+":"+parseInt(s-m*60))+lnSep+"' data-tm='"+s+"'></a>" | |
+"<span class='_xxj_ft_ln'>"+text+"</span>" | |
lastTime = lI.startTime; | |
} | |
tv.innerHTML=html(span); | |
// attach ele to array | |
lrcArr = lrcs; | |
lcN = 0; | |
var cc=0; | |
var sz = tv.childElementCount; | |
for(var i=0;i<sz,cc<lrcArr.length;i++) { | |
if(tv.children[i].className==="_xxj_ft_ln") { | |
lrcArr[cc++].ele=tv.children[i]; | |
} | |
} | |
window.lrcArr=lrcArr; | |
//console.log(lrcArr); | |
} | |
installBtn(); | |
win.APFT = AppendFulltext; | |
// trigger when loading new page | |
// (actually this would also trigger when first loading, that's not what we want, that's why we need to use firsr_load === false) | |
// (new Material design version would trigger this "yt-navigate-finish" event. old version would not.) | |
var body = document.getElementsByTagName("body")[0]; | |
body.addEventListener("yt-navigate-finish", function (event) { | |
if (is_video_page()&&autoFTM) { | |
if(build_cc_menu()) { | |
var st = MenuSty; | |
if(st.display!="") { | |
st.display="" | |
} | |
} | |
} | |
}); | |
// trigger when loading new page | |
// (old version would trigger "spfdone" event. new Material design version not sure yet.) | |
window.addEventListener("spfdone", function (e) { | |
//if (is_video_page()) { | |
// remove_dwnld_btn(); | |
// var checkExist = setInterval(function () { | |
// if (unsafeWindow.watch7_headline) { | |
// init(); | |
// clearInterval(checkExist); | |
// } | |
// }, 330); | |
//} | |
}); | |
function is_video_page() { | |
return get_vid() !== null; | |
} | |
function get_vid() { | |
if(isBY==1) { | |
Data = (gt('ytd-app')||gt('ytd-app', parent.document)).data.playerResponse; | |
return Data.videoDetails.videoId; | |
} | |
return lastVid; | |
} | |
//https://stackoverflow.com/questions/11582512/how-to-get-url-parameters-with-javascript/11582513#11582513 | |
function getURLParameter(name) { | |
return decodeURIComponent((new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search) || [null, ''])[1].replace(/\+/g, '%20')) || null; | |
} | |
// https://stackoverflow.com/questions/32142656/get-youtube-captions#58435817 | |
function buildXmlurl(videoId, loc) { | |
return `${baseUrl}?lang=${loc}&v=${videoId}`;//&fmt=json3 | |
} | |
// pull the selected caption. | |
function pullLyrics(e, d) { | |
//var url; | |
// if(e==0) { | |
// console.log("auto"); | |
// url = get_auto_xml_url(); | |
// console.log("auto", url); | |
// } | |
// e = tracks[e] | |
// if(e) { | |
// if(!e.srt) | |
// fetch(url||buildXmlurl(get_vid(), e.lang_code)) | |
// .then(v => v.text()) | |
// .then(v => (new window.DOMParser()).parseFromString(v, "text/xml")) | |
// .then(v => { | |
// v = buildSrtFromXML(v); | |
// e.srt = v; | |
// appendFulltext(e, d) | |
// }) | |
// else appendFulltext(e, d) | |
// } | |
var track = tracks[e]; | |
var subtitle_url = track.subtitle_url||track.baseUrl; | |
debug('fetching caption track url=', subtitle_url, track); | |
if(!subtitle_url) { | |
throw '字幕地址为空'; | |
} | |
if(!subtitle_url.includes('://')) { | |
// bilibili new api does not have https prefix | |
subtitle_url = "https:" + subtitle_url; | |
} | |
var url = subtitle_url; | |
if(bank[url]) { | |
track.srt = bank[url]; | |
AppendFulltext(track, d) | |
} else { | |
fetch(url) | |
.then(v => v.text()) | |
.then(v => { | |
debug('fetched caption track=', v); | |
var srt; | |
if(isBY==0) { | |
srt = buildSrtFromJson(v); | |
} else { | |
const trusted = html(v); | |
srt = new window.DOMParser().parseFromString(trusted, 'text/html'); | |
// win._src = srt; | |
srt = buildSrtFromXML(srt); | |
debug('parsed=', srt); | |
// srt = buildSrtFromXML((new window.DOMParser()).parseFromString(v, "text/xml")); | |
} | |
bank[url] = track.srt = srt; | |
AppendFulltext(track, d) | |
}) | |
} | |
} | |
function buildMenu(e, cid){ | |
return (`<div class="ytp-menuitem" aria-haspopup="true" role="menuitem" tabindex="${e.cid||cid}"> | |
<div class="ytp-menuitem-icon"></div> | |
<div class="ytp-menuitem-label"> | |
${e.lan_doc||e.name.simpleText} | |
</div> | |
<div class="ytp-menuitem-content"> | |
下载 | |
</div> | |
</div>`); | |
} | |
function menuClick(e){ | |
debug('menuClick', e); | |
var t = e.target; | |
var i = parseInt(t.parentNode.getAttribute("tabindex")); | |
if(i==i) { | |
if(t.className==="ytp-menuitem-content") { | |
// 下载 | |
pullLyrics(i, 1); | |
} else { | |
// 查看 | |
installTextPane(120); | |
pullLyrics(i); | |
} | |
} | |
MenuSty.display="none"; | |
setTimeout(()=>{ | |
MenuSty.display="none"; | |
;debug('消失了吗', MenuSty, MenuSty.display); | |
}, 1); | |
t.blur(); | |
} | |
function build_cc_menu(src) { | |
var vid = get_vid(); | |
if(vid==Btn.parsedVid && Menu && Menu.children.length) { | |
return false; | |
} | |
Btn.parsedVid=vid; | |
if(loadOnStart) { | |
src=1; | |
} | |
function onMenuLoad(tmp) { | |
Menu.innerHTML=html(tmp); | |
if(Menu && Menu.children) { | |
for (var i=0,ch=Menu.children,len=ch.length; i < len; i++) { | |
ch[i].onclick = menuClick; | |
// if(autosel==i) { | |
// initYFT(120); | |
// pullLyrics(i); | |
// } | |
} | |
} | |
} | |
var ibf = Btn; // unsafeWindow.movie_player | |
// todo check auto caption exists | |
if((!Menu||!Menu.parentNode) && ibf) { | |
var tmp = document.createElement("div"); | |
ibf.appendChild(tmp); | |
// menuData | |
tmp.innerHTML = html(`<div class="ytp-popup ytp-fulltext-menu" data-layer="6" id="yft-select" | |
style="width: 251px; height: 137px; display: block;"> | |
<div class="ytp-panel _xxj_menu" style="min-width: 250px; width: 251px; height: 137px;"> | |
<div class="ytp-panel-menu" role="menu" style="height: 137px;"></div> | |
</div> | |
</div>`); | |
MenuSty = tmp.firstElementChild.style; | |
MenuSty.position='absolute'; | |
MenuSty.background='#000000cf'; | |
if(isBY==0) { | |
MenuSty.left='-100px'; | |
} | |
Menu = gc('_xxj_menu', tmp); | |
// if(src==1 && !autoFTM) { | |
// MenuSty.display = "none"; | |
// } | |
debug('Menu', Menu); | |
} | |
if(Menu) { | |
try{ | |
// bilibili 需要根据视频aid&cid获取字幕列表 | |
if(isBY==0) { | |
Menu.innerHTML = ""; | |
var url = `https://api.bilibili.com/x/player/v2?${vid}`; | |
debug("loading_list, url=", url); | |
function onload(res, xhr) { | |
debug('得到', res, xhr) | |
try{ | |
bank[vid] = res; | |
var autosel=-1 | |
, arr=res.data.subtitle.subtitles | |
, tmp="" | |
; | |
tracks.length = 0; | |
for (var i=0, len=arr.length;i<len;i++) { | |
tracks.push(arr[i]); | |
tmp+=buildMenu(arr[i], i); | |
} | |
if(src==1) { | |
autosel=0; | |
} | |
debug('tmp::', arr.length); | |
onMenuLoad(tmp) | |
} catch(e) { | |
console.log(e); | |
} | |
// todo ... load from file | |
} | |
if(bank[vid]) { | |
onload(bank[vid]); | |
} else { | |
loadJson(url, onload); | |
} | |
} | |
// youtube 字幕列表直接给我们了,无需解析api | |
else { | |
var autosel=-1 | |
, arr=Data.captions.playerCaptionsTracklistRenderer.captionTracks | |
, tmp="", xml | |
; | |
tracks.length = 0; | |
for (var i=0, len=arr.length;i<len;i++) { | |
tracks.push(arr[i]); | |
tmp += buildMenu(arr[i], i) | |
} | |
onMenuLoad(tmp) | |
} | |
} catch(e) { | |
debug('获取字幕列表失败!', e) | |
Btn.parsedVid=""; | |
} | |
} else { | |
Btn.parsedVid=""; | |
} | |
debug('tracks', arr); | |
debug("autosel", autosel); | |
return true; | |
} | |
// 处理时间. 比如 start="671.33" start="37.64" start="12" start="23.029" | |
// 处理成 srt 时间, 比如 00:00:00,090 00:00:08,460 00:10:29,350 | |
function process_time(s) { | |
s = s.toFixed(3); | |
// 671.33 -> 671.330 | |
// 671 -> 671.000 | |
var array = s.split('.'); | |
// 把开始时间根据句号分割 | |
// 671.330 会分割成数组: [671, 330] | |
var Hour = 0; | |
var Minute = 0; | |
var Second = array[0]; // 671 | |
var MilliSecond = array[1]; // 330 | |
// 先声明下变量, 待会把这几个拼好就行了 | |
// 我们来处理秒数. 把"分钟"和"小时"除出来 | |
if (Second >= 60) { | |
Minute = Math.floor(Second / 60); | |
Second = Second - Minute * 60; | |
// 把 秒 拆成 分钟和秒, 比如121秒, 拆成2分钟1秒 | |
Hour = Math.floor(Minute / 60); | |
Minute = Minute - Hour * 60; | |
// 把 分钟 拆成 小时和分钟, 比如700分钟, 拆成11小时40分钟 | |
} | |
// 分钟,如果位数不够两位就变成两位,下面两个if语句的作用也是一样。 | |
if (Minute < 10) { | |
Minute = '0' + Minute; | |
} | |
// 小时 | |
if (Hour < 10) { | |
Hour = '0' + Hour; | |
} | |
// 秒 | |
if (Second < 10) { | |
Second = '0' + Second; | |
} | |
return Hour + ':' + Minute + ':' + Second + ',' + MilliSecond; | |
} | |
// copy from: https://gist.github.com/danallison/3ec9d5314788b337b682 | |
// Thanks! https://github.com/danallison | |
// work in Chrome 66 | |
// test passed: 2018-5-19 | |
function downloadString(text, fileType, fileName) { | |
var blob = new Blob([text], {type: fileType}); | |
var a = document.createElement('a'); | |
a.download = fileName; | |
a.href = URL.createObjectURL(blob); | |
a.dataset.downloadurl = [fileType, a.download, a.href].join(':'); | |
a.style.display = "none"; | |
document.body.appendChild(a); | |
a.click(); | |
document.body.removeChild(a); | |
setTimeout(function () { | |
URL.revokeObjectURL(a.href); | |
}, 1500); | |
} | |
// https://css-tricks.com/snippets/javascript/unescape-html-in-js/ | |
// turn HTML entity back to text, example: " should be " | |
function htmlDecode(input) { | |
var e = document.createElement('div'); | |
const trusted = html(input); | |
e.innerHTML = trusted; | |
return e.childNodes.length === 0 ? "" : e.childNodes[0].nodeValue; | |
// var e = document.createElement('div'); | |
// e.innerHTML = input; | |
// return e.childNodes.length === 0 ? "" : e.childNodes[0].nodeValue; | |
} | |
// return URL or null; | |
// later we can send a AJAX and get XML subtitle | |
function get_auto_xml_url() { | |
try { | |
var captionTracks = get_captionTracks() | |
for (var index in captionTracks) { | |
var caption = captionTracks[index]; | |
if (caption.kind === 'asr') { | |
return captionTracks[index].baseUrl; | |
} | |
// ASR – A caption track generated using automatic speech recognition. | |
// https://developers.google.com/youtube/v3/docs/captions | |
} | |
return false; | |
} catch (e) { | |
console.log(e); | |
return false; | |
} | |
} | |
async function get_auto_subtitle() { | |
var url = get_auto_xml_url(); | |
console.log("dwnld_auto_url::", url); | |
if (url == false) { | |
return false; | |
} | |
var result = await getUrl(url) | |
return result | |
} | |
// Youtube return XML. | |
// input: Youtube XML format | |
// output: SRT format | |
function buildSrtFromXML(youtube_xml_string) { | |
if (youtube_xml_string === '') { | |
return false; | |
} | |
var text = youtube_xml_string.getElementsByTagName('text'); | |
var result = '\uFEFF'; | |
var len = text.length; | |
for (var i = 0; i < len; i++) { | |
var content = text[i].textContent.toString(); | |
content = content.replace(/(<([^>]+)>)/ig, ""); // remove all html tag. | |
var start = text[i].getAttribute('start'); | |
var end = parseFloat(text[i].getAttribute('start')) + parseFloat(text[i].getAttribute('dur')); | |
result = result + (i + 1) + "\n"; | |
// 1 | |
if (i + 1 >= len) { | |
end = parseFloat(text[i].getAttribute('start')) + parseFloat(text[i].getAttribute('dur')); | |
} else { | |
end = text[i + 1].getAttribute('start'); | |
} | |
var start_time = process_time(parseFloat(start)); | |
var end_time = process_time(parseFloat(end)); | |
result = result + start_time; | |
result = result + ' --> '; | |
result = result + end_time + "\n"; | |
// 00:00:01,939 --> 00:00:04,350 | |
content = htmlDecode(content); | |
// turn HTML entity back to text. example: ' back to apostrophe (') | |
result = result + content + "\n" + "\n"; | |
} | |
return result; | |
} | |
// bilibili return JSON. | |
function buildSrtFromJson(bilibili_json_string) { | |
var json = JSON.parse(bilibili_json_string); | |
debug('buildSrtFromJson, json=', json); | |
var arr = json.body, result = '\uFEFF'; | |
for (var i = 0, len=arr.length; i < len; i++) { | |
var content = arr[i].content; | |
content = content.replace(/(<([^>]+)>)/ig, ""); // remove all html tag. | |
var start = arr[i].from; | |
var end = arr[i].to; | |
// 1 | |
result = result + (i + 1) + "\n"; | |
var start_time = process_time(parseFloat(start)); | |
var end_time = process_time(parseFloat(end)); | |
result = result + start_time; | |
result = result + ' --> '; | |
result = result + end_time + "\n"; | |
// 00:00:01,939 --> 00:00:04,350 | |
// content = htmlDecode(content); | |
// turn HTML entity back to text. example: ' back to apostrophe (') | |
result = result + content + "\n" + "\n"; | |
} | |
return result; | |
} | |
function get_captionTracks() { | |
var json = null | |
if (win.youtube_playerResponse_1c7) { | |
json = youtube_playerResponse_1c7; | |
} else if(ytplayer.config.args.player_response) { | |
let raw_string = ytplayer.config.args.player_response; | |
json = JSON.parse(raw_string); | |
} else if (ytplayer.config.args.raw_player_response) { | |
json = ytplayer.config.args.raw_player_response; | |
} | |
let captionTracks = json.captions.playerCaptionsTracklistRenderer.captionTracks; | |
return captionTracks | |
} | |
function loadJson(url,cb,parm){ | |
//debug('loadJson!!!', url,parm) | |
var req = new XMLHttpRequest(); | |
req.open(parm?'POST':'GET', url, true); | |
req.responseType = 'json'; | |
// bilibili API need SESSDATA key from browser's cookies, carry cookies of session for it | |
req.withCredentials = true; | |
if(cb){ | |
req.onload = function() { | |
cb(req.response, req); | |
}; | |
req.onerror = function() { | |
cb(0, req); | |
}; | |
} | |
//req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); | |
//x.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); | |
req.send(parm); | |
} | |
// https://stackoverflow.com/questions/48969495/in-javascript-how-do-i-should-i-use-async-await-with-xmlhttprequest | |
function makeRequest(method, url, load, type) { | |
return new Promise(function (resolve, reject) { | |
let xhr = new XMLHttpRequest(); | |
xhr.responseType = type; | |
//xhr.timeout = 2000; | |
xhr.onload = function () { | |
debug('makeRequest, onload::', this.status, xhr.statusText); | |
if (this.status >= 200 && this.status < 300) { | |
if(load) { | |
load(xhr); | |
resolve(''); | |
} else { | |
resolve(xhr); | |
} | |
} else { | |
debug('makeRequest, 发生错误::', this.status, xhr.statusText); | |
reject({ | |
status: this.status, | |
statusText: xhr.statusText | |
}); | |
} | |
}; | |
xhr.onerror = function () { | |
debug('makeRequest, 发生错误::', this.status, xhr.statusText); | |
reject({ | |
status: this.status, | |
statusText: xhr.statusText | |
}); | |
}; | |
xhr.open(method, url, true); // set async to true to avoid 'sync responseType error' | |
xhr.send(); | |
}); | |
} | |
async function getUrl(url) { | |
return makeRequest("GET", url); | |
} | |
// de | |
// var hack =1; | |
// if(parent!=win) { | |
// win.originalRemove_ ||= Element.prototype.removeChild; | |
// // Override the remove method | |
// Element.prototype.removeChild = function(e) { | |
// de('removing...', e.className, e) | |
// win.originalRemove_.call(this, e); | |
// }; | |
// } | |
// })(); |