让播放器在AB点之间循环!
// ==UserScript== // @name 哔哩哔哩AB循环 // @namespace ckylin-script-bilibili-abloop // @version 0.11 // @description 让播放器在AB点之间循环! // @author CKylinMC // @match https://www.bilibili.com/video/* // @grant unsafeWindow // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_getValue // @grant GM_setValue // @license GPLv3 License // ==/UserScript== (function() { 'use strict'; //if (!('ABLOOPDEBUG' in unsafeWindow)) unsafeWindow.ABLOOPDEBUG = false; const get = q => document.querySelector(q); const wait = t => new Promise(r => setTimeout(r, t)); const waitForPageVisible = async () => document.hidden && new Promise(r=>document.addEventListener("visibilitychange",r)); const log = (...m) => console.log('[ABLoop]', ...m); //const d = (...m) => unsafeWindow.ABLOOPDEBUG ? console.log('[ABLoop Debug]', ...m) : 0; const registerMenu = (text, callback) => menuIds.push(GM_registerMenuCommand(text, callback)); const clearMenu = () => { menuIds.forEach(id => GM_unregisterMenuCommand(id)); menuIds = []; }; const getTotalTime = async () => (await waitForAttribute(cfg.video,'duration'))||unsafeWindow.player?.getDuration(); const getCurrentTime = () => cfg.video.currentTime||unsafeWindow.player?.getCurrentTime(); const setTime = (t,countincrease=false) => [unsafeWindow.player.seek(t),countincrease ? (function(){cfg.loopcounter+=1;showAnim({ico:'motion-play',txt:`回到开头 已循环 ${cfg.loopcounter} 次`})})() : null]; const play = () => unsafeWindow.player.play(); const pause = () => unsafeWindow.player.pause(); const cfg = { a: 0, b: 999, loopcounter: 0, video: null, isLooping: false, showAnimTip: true, initok: false, listener: () => getCurrentTime() >= (cfg.b-0.2) ? setTime(cfg.a,true) : 0 } const guibar = { toBar: null, fromBar:null } let menuIds = []; let menus = {}; cfg.showAnimTip = ["null","undefined"].includes(typeof(GM_getValue('animtipenabled')))?cfg.showAnimTip:GM_getValue('animtipenabled'); async function playerReady(){ let i=50; while(--i>=0){ await wait(200); if(!('player' in unsafeWindow)) continue; if(!('isInitialized' in unsafeWindow.player)) continue; if(!unsafeWindow.player.isInitialized()) continue; return true; } return false; } async function waitForDom(q) { let i = 50; let dom; while (--i >= 0) { if (dom = get(q)) break; await wait(100); } return dom; } async function waitForAttribute(q, attr) { let i = 50; let value; while (--i >= 0) { if ((attr in q) && q[attr] != null) { value = q[attr]; break; } await wait(100); } return value; } function applyMenus() { clearMenu(); for (let item in menus) { if(!menus.hasOwnProperty(item)) continue; let menu = menus[item]; registerMenu(menu.text, menu.callback); } } function setMenu(id,text,callback,noapply = false) { menus[id] = { text, callback }; if (!noapply) applyMenus(); } function triggerAPoint() { cfg.a = getCurrentTime(); //d('getCurrentTime', getCurrentTime()); setFromBarPos(); setAPointMenu(); showAnim({ico:"alpha-a-box",txt:`起始点已设置: ${cfg.a}`}); } function triggerBPoint() { cfg.b = getCurrentTime(); //d('getCurrentTime', getCurrentTime()); setToBarPos(); setBPointMenu(); showAnim({ico:"alpha-b-box",txt:`结束点已设置: ${cfg.b}`}); } function triggerToggleDoStop(fast=false) { if(!fast)cfg.isLooping = !cfg.isLooping; cfg.video.removeEventListener('timeupdate',cfg.listener); pause(); if(!fast)hideBars(); if(!fast)setLoopListenerMenu(false); if(!fast)forgiveAllPoint() if(!fast)showAnim({ico:"play",txt:`回到正常播放模式`,icoextra:"moveright"}); } function triggerToggleDoStart(autostart=true) { triggerToggleDoStop(true); cfg.loopcounter = 0; cfg.isLooping = !cfg.isLooping; cfg.video.addEventListener('timeupdate',cfg.listener); setTime(cfg.a); if(autostart)play(); showBars(); setLoopListenerMenu(false); saveAllPoint() showAnim({ico:"sync",txt:`开始循环 ${cfg.a} - ${cfg.b}`,icoextra:"rotate"}); } function triggerToggleDoAuto() { if (cfg.isLooping) { triggerToggleDoStop(); } else { triggerToggleDoStart(); } } function triggerAnimTipStatus(update=true,noapply = false){ if(update){ cfg.showAnimTip=!cfg.showAnimTip; } GM_setValue("animtipenabled",cfg.showAnimTip); cfg.showAnimTip ? setAnimTipEnabled(noapply) : setAnimTipDisabled(noapply); initAnimCss(); } function setAnimTipEnabled(noapply = false){ setMenu("ANIMTIP", "点此不再显示动作提示框", triggerAnimTipStatus, noapply); } function setAnimTipDisabled(noapply = false){ setMenu("ANIMTIP", "点此恢复显示动作提示框", triggerAnimTipStatus, noapply); } function setAPointMenu(noapply = false) { setMenu("APOINT", "设置A点 (当前A点:" + (Math.floor(cfg.a*100)/100) + ")", triggerAPoint, noapply); } function setBPointMenu(noapply = false) { setMenu("BPOINT", "设置B点 (当前B点:" + (Math.floor(cfg.b*100)/100) + ")", triggerBPoint, noapply); } function setSavePointMenu(noapply = false) { setMenu("SAVEPOINT", "记住此页循环设置", saveAllPoint, noapply); } function setForgivePointMenu(noapply = false) { setMenu("SAVEPOINT", "清除此页循环设置", forgiveAllPoint, noapply); } function setLoopListenerMenu(noapply = false) { if (cfg.isLooping) { setMenu("LOOP", "停止循环", triggerToggleDoStop, noapply); } else { setMenu("LOOP", "开始循环", triggerToggleDoStart, noapply); } } function removeDom(...qs){ qs.forEach(q=>{ if (q) { let target; if (q instanceof Element) target = q; else target = document.querySelectorAll(q); if(target&&target.length){ target.forEach(e=>e.remove()); } } }); } function newBar() { let bar = document.createElement("div"); bar.classList.add("bui-bar"); bar.classList.add("abloop-custombar"); bar.style.transform = "scaleX(0)"; return bar; } function addStyleOnce(id,css) { let style = document.querySelector("#abloop-css-" + id); if (style) return; style = document.createElement("style"); style.id = "abloop-css-" + id; style.innerHTML = css; document.body.appendChild(style); return; } async function setAPointBarPos(){ let point = cfg.a; let duration = await getTotalTime(); let dt = point/duration; if (!guibar.fromBar) await createMarkBar(); const playbar = await waitForDom(".bui-bar.bui-bar-normal"); if (!playbar) return; guibar.fromBar.style.transform = `scaleX(${dt})`; showBarA(); } async function setBPointBarPos(){ let point = cfg.b; let duration = await getTotalTime(); let dt = point/duration; if (!guibar.toBar) await createMarkBar(); const playbar = await waitForDom(".bui-bar.bui-bar-normal"); if (!playbar) return; guibar.toBar.style.transform = `scaleX(${dt})`; showBarA(); } async function setFromBarPos() { if (!guibar.fromBar) await createMarkBar(); const playbar = await waitForDom(".bui-bar.bui-bar-normal"); if (!playbar) return; guibar.fromBar.style.transform = playbar.style.transform; showBarA(); } async function setToBarPos() { if (!guibar.toBar) await createMarkBar(); const playbar = await waitForDom(".bui-bar.bui-bar-normal"); if (!playbar) return; guibar.toBar.style.transform = playbar.style.transform; showBarB() } function showBars() { let bars = document.querySelectorAll(".abloop-custombar"); bars.forEach(bar => { if (!bar.classList.contains("show")) bar.classList.add("show"); }) } function showBarA() { if (guibar.fromBar && !guibar.fromBar.classList.contains("show")) guibar.fromBar.classList.add("show"); } function showBarB() { if (guibar.toBar && !guibar.toBar.classList.contains("show")) guibar.toBar.classList.add("show"); } function hideBars() { let bars = document.querySelectorAll(".abloop-custombar"); bars.forEach(bar => { if (bar.classList.contains("show")) bar.classList.remove("show"); }) } async function createMarkBar(){ removeDom(guibar.fromBar, guibar.toBar); const playbar = await waitForDom(".bui-bar.bui-bar-normal"); if (!playbar) return; addStyleOnce('markbar', ` .abloop-custombar{ opacity: 0; transform: scale(0); } .abloop-custombar.show{ opacity: 1!important; transition: transform ease .3s,opacity .2s; } `); guibar.fromBar = newBar(); guibar.fromBar.style.background = "#9e9e9e"; guibar.fromBar.style.backgroundColor = "#9e9e9e"; guibar.fromBar.style.transform = "scale(0)"; guibar.fromBar.setAttribute('style', 'background:#9e9e9e!important'); playbar.parentNode.appendChild(guibar.fromBar, playbar); guibar.toBar = newBar(); guibar.toBar.style.background = "#8bc34a"; guibar.toBar.style.backgroundColor = "#8bc34a"; guibar.toBar.style.transform = "scale(1)"; guibar.toBar.setAttribute('style', 'background:#8bc34a!important'); playbar.parentNode.insertBefore(guibar.toBar, playbar); } function hotKeyHandler(e) { log(1,e); if (['KeyA', 'KeyB', 'KeyO'].includes(e.code)) { log(2,e); if (e.ctrlKey || e.altKey || e.shiftKey) return; if ([...e.path.filter(t => t.tagName == "TEXTAREA" || t.tagName == "INPUT")].length) return; switch (e.key) { case "a": triggerAPoint(); e.preventDefault(); break; case "b": triggerBPoint(); e.preventDefault(); break; case "o": triggerToggleDoAuto(); e.preventDefault(); break; } } } function regHotKey() { unsafeWindow.removeEventListener('keypress', hotKeyHandler); unsafeWindow.addEventListener('keypress', hotKeyHandler); } function str2float(str,fallback=-1){ try{ let num = parseFloat(str); if(isNaN(num)) return fallback; if(typeof(num)==='undefined') return fallback; return num; }catch(e){ return fallback; } } function forgiveAllPoint(){ GM_setValue(`a:${location.pathname}`,-1); GM_setValue(`b:${location.pathname}`,-1); setSavePointMenu(); } function saveAllPoint(){ saveAPoint(); saveBPoint(); setForgivePointMenu(); } function saveAPoint(){ GM_setValue(`a:${location.pathname}`,cfg.a); } function saveBPoint(){ GM_setValue(`b:${location.pathname}`,cfg.b); } function getAPoint(){ return str2float(GM_getValue(`a:${location.pathname}`)); } function getBPoint(){ return str2float(GM_getValue(`b:${location.pathname}`)); } async function loadFromSavedData(){ let a = getAPoint(); let b = getBPoint(); let loopauto = false; if(a>=0){ cfg.a = a; setAPointBarPos(); setAPointMenu(false); loopauto=true; } if(b>=0){ cfg.b = Math.min(b,await getTotalTime()); setBPointBarPos(); setBPointMenu(false); loopauto=true; } if(loopauto) { setForgivePointMenu(); triggerToggleDoStart(false); } return loopauto; } async function loadFromURL(){ let url = new URL(location.href); let a = str2float(url.searchParams.get('ta')); let b = str2float(url.searchParams.get('tb')); let loopauto = false; if(a>=0){ cfg.a = a; saveAPoint(); setAPointBarPos(); setAPointMenu(false); loopauto=true; } if(b>=0){ cfg.b = Math.min(b,await getTotalTime()); saveBPoint(); setBPointBarPos(); setBPointMenu(false); loopauto=true; } if(loopauto) { setForgivePointMenu(); triggerToggleDoStart(false); } return loopauto; } function removeAllAnim(){ removeDom(".abloop-loopcontainer",".abloop-loopanim",".abloop-asetanim",".abloop-bsetanim",".abloop-stopanim"); } function makeAnimContainer(extraClass = "",outside=false){ const container = document.createElement("div"); container.classList.add("abloop-loopcontainer",...extraClass.split(' ')); let target = outside?null:document.querySelector("#bilibiliPlayer"); (target||document.body).appendChild(container); return container; } function makeIcon(name="",extras=""){ const icon = document.createElement("span"); icon.className = "mdi mdi-"+name+" abloop-anim-icon abloop-ico-"+extras; return icon; } function makeTipText(text=""){ const tip = document.createElement("span"); tip.className = "abloop-anim-tip"; tip.innerHTML = text; return tip; } async function showAnim(options){ if(!cfg.showAnimTip) return; await waitForDom("#abloop-css-anim-tip-css"); const{ icoextra = '', forwards = false, ico = '', txt = 'Empty Tip', waitPlayer = true, injectToBody = false, } = options; if(waitPlayer)await playerReady(); removeAllAnim(); const base = makeAnimContainer("abloop-loopanim"+(forwards ? " forwards" : ""),injectToBody); const icon = makeIcon(ico,icoextra); base.appendChild(icon); const tip = makeTipText(txt); base.appendChild(tip); } async function handleLoadFail(){ log("No player found on this page."); initAnimCss(); unsafeWindow.abloop_reinit = ()=>[ delete unsafeWindow.abloop_reinit, init(true),showAnim({ waitPlayer:false, injectToBody:true, ico:"alert-circle-check-outline", txt:"正在尝试重新加载" })]; unsafeWindow.abloop_ignore = ()=>[showAnim({ waitPlayer:false, injectToBody:true, ico:"alert-circle-check-outline", txt:"已忽略。本次播放将无法加载AB循环功能,可以刷新重试。" }),delete unsafeWindow.abloop_ignore]; showAnim({waitPlayer:false,injectToBody:true,forwards:true,ico:"alert-circle-outline",txt:`未能按时加载。<br><span style="padding:0 10px;display:inline-block">如果你是后台打开的标签页面,这可能很常见。<br>你可以尝试:<a style="color:#83ff7e" href="javascript:void(0)" onclick="abloop_reinit()">重新加载</a> 或 <a style="color:#83ff7e" href="javascript:void(0)" onclick="abloop_ignore()">暂时禁用AB循环</a></span>`}); } unsafeWindow.abloop_testfail = handleLoadFail; function initAnimCss(){ if(cfg.showAnimTip)setTimeout(() => { if (!document.querySelector("#mdiiconcss")) document.head.innerHTML += `<link id="mdiiconcss" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/[email protected]/css/materialdesignicons.min.css"/>` addStyleOnce('anim-tip-css', ` .abloop-anim-icon{ margin: 0 10px; } .abloop-ico-rotate::before{ animation: abloop-ico-anim-rotate forwards .5s .5s ease-in-out; } .abloop-ico-moveright::before{ animation: abloop-ico-anim-move forwards .5s .5s ease-in-out; } .abloop-loopcontainer{ position: fixed; top: 0; left: 50%; max-height: 3rem !important; transform: translateX(-50%); border-radius: 0 0 6px 6px; z-index: 900000; background: #000000a1; backdrop-filter: blur(4px); text-shadow: 0 0 3px white; color:white; font-size: 1.5rem; min-height: 3rem; transition: all .3s; padding-right: 10px; overflow: hidden; white-space: nowrap; line-height: 3rem; animation: abloop-in forwards 1.2s ease-in-out, abloop-in forwards reverse 1.2s 4.2s ease-in-out; } .abloop-loopcontainer:not(.forwards)::before { background: #ffffff30; content: " "; position: fixed; top: 0; left: 50%; height: 100%; width: 100%; transform: translateX(-150%); animation: abloop-progress forwards 3s 1.2s linear, abloop-fadeout forwards .3s 4.2s linear; } .abloop-loopcontainer:hover{ max-height: 10rem !important; transition: all .3s ease-in-out; } .abloop-loopcontainer.forwards{ animation: abloop-in forwards 1.2s ease-in-out !important; } @keyframes abloop-in{ 0%{ opacity: 0; max-width: 2.2rem; top:-100%; } 45%,55%{ opacity:1; top:0rem; max-width: 2.2rem; } 100%{ max-width: 40rem; } } @keyframes abloop-progress{ 0%{ transform: translateX(-150%); } 100%{ transform: translateX(-50%); } } @keyframes abloop-fadeout{ 0%{ opacity: 1; } 100%{ opacity: 0; } } @keyframes abloop-ico-anim-move{ 0%,100%{ transform: translateX(0px); } 50%{ transform: translateX(10px); } } @keyframes abloop-ico-anim-rotate{ 0%{ transform: rotate(0deg); } 100%{ transform: rotate(-180deg); } } `); }, 300); } async function init(tip_when_ok=false) { cfg.initok = false; await waitForPageVisible(); log("Waiting for player to be ready..."); if(!(await playerReady())) return handleLoadFail(); log("Player ready"); initAnimCss(); log("Waiting for dom..."); cfg.video = await waitForDom(".bilibili-player-video video,bwp-video,.bpx-player-video-wrap video"); //d('video', get(".bilibili-player-video video")); //d('total', await getTotalTime()); cfg.video = get(".bilibili-player-video video,bwp-video,.bpx-player-video-wrap video"); log("Dom OK"); cfg.b = (await getTotalTime())-0.1; triggerAnimTipStatus(false,true); setAPointMenu(true); setBPointMenu(true); setLoopListenerMenu(true); setSavePointMenu(); await createMarkBar(); regHotKey(); log("Initialization OK"); if(tip_when_ok){ showAnim({ ico:"alert-circle-check-outline", txt:"加载成功" }); } cfg.initok = true; if((await loadFromSavedData())+(await loadFromURL())) showBars(); } // API unsafeWindow.abloop_setAPoint = (t=getCurrentTime(),remeber=false)=>{ cfg.a = t; if(remeber)saveAPoint(); setAPointBarPos(); setAPointMenu(); } unsafeWindow.abloop_setBPoint = (t=getCurrentTime(),remeber=false)=>{ cfg.b = t; if(remeber)saveBPoint(); setBPointBarPos(); setBPointMenu(); } unsafeWindow.abloop_isinited = ()=>cfg.initok; unsafeWindow.abloop_isLooping = ()=>cfg.isLooping; unsafeWindow.abloop_getLoopCount = ()=>cfg.loopcounter; unsafeWindow.abloop_startloop = triggerToggleDoStart; unsafeWindow.abloop_stoploop = triggerToggleDoStop; unsafeWindow.abloop_showTip = showAnim; unsafeWindow.abloop_setTipStatus = (enabled=cfg.showAnimTip)=>cfg.showAnimTip=enabled; unsafeWindow.abloop_init = init; init(); })();