// ==UserScript==
// @name B站循环助手-稳定版
// @namespace bilibili-replayer
// @version 1.5
// @description 稳定可靠的AB点循环工具,适配最新B站页面结构
// @author dms
// @match https://www.bilibili.com/video/BV*
// @match https://www.bilibili.com/bangumi/play/ep*
// @match https://www.bilibili.com/medialist/play/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=bilibili.com
// @grant GM_notification
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
(function() {
'use strict';
// 存储管理
const Storage = {
savePoint: (index, value) => {
try {
GM_setValue(`Point_${index}`, value);
return true;
} catch(e) {
console.error('保存点位失败:', e);
return false;
}
},
getPoint: (index) => {
try {
return GM_getValue(`Point_${index}`, null);
} catch(e) {
console.error('获取点位失败:', e);
return null;
}
}
};
// 工具函数
const Utils = {
isNormalVideo: /^(https?:\/\/(www\.)bilibili\.com\/video\/(?:BV|AV)\w+).*/i.test(window.location.href),
async copyText(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch(e) {
console.warn('现代复制API失败,使用传统方法');
}
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
textArea.remove();
return true;
} catch(e) {
console.error('复制失败:', e);
return false;
}
},
createButton(text, className, parent) {
const button = document.createElement('div');
className.split(' ').forEach(c => button.classList.add(c));
button.innerText = text;
parent.appendChild(button);
return button;
},
showNotification(text, title = '提示', timeout = 2000) {
GM_notification({
text,
title,
timeout
});
}
};
// UI创建和管理
const UI = {
createStyle: () => `
#replayer-toolbar {
width: 100%;
height: 1.2rem;
font-size: 0.7rem;
line-height: 1.2rem;
margin: 0;
padding: 0;
position: relative;
z-index: 100;
}
#replayer-toolbar::after {
content: " ";
display: block;
clear: both;
}
.tool-item {
float: left;
padding: 0 0.25rem;
border-radius: .2rem;
}
.tool-button {
border: 1px solid rgba(0, 0, 0, .1);
cursor: pointer;
transition: all 0.2s ease;
}
.tool-button:hover {
background-color: rgba(0, 174, 236, 0.1);
}
.active-button {
background-color: #00aeec;
color: white;
}
.hide {
display: none;
}
`
};
class VideoController {
constructor(video) {
this.video = video;
this.points = [0, video.duration-1];
this.pointButtons = [];
this.animationFrameId = null;
this.lastTime = 0;
}
setPoint(index, value) {
if(value && value.trim()) {
if(!/^(\d+h)?(\d+m)?\d+(\.\d+)?$/i.test(value)) {
Utils.showNotification('时间格式有误,请检查输入格式', '输入错误');
return;
}
if(typeof(value) === 'string') {
const hms = value.split(/h|m/g);
const h = /\d+h/.test(value) ? +hms[0] : 0;
const m = /\d+m/.test(value) ? +hms[hms.length-2] : 0;
const s = +hms[hms.length-1];
this.points[index] = h*3600 + m*60 + s;
} else {
this.points[index] = value;
}
this.pointButtons[index].classList.add('active-button');
Storage.savePoint(index, this.points[index]);
return;
}
this.points[index] = index ? this.video.duration-1 : 0;
this.pointButtons[index].classList.remove('active-button');
Storage.savePoint(index, null);
}
startLoop(button) {
if(this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
button.classList.remove('active-button');
return;
}
button.classList.add('active-button');
const checkLoop = (timestamp) => {
if (timestamp - this.lastTime > 200) {
const A = this.points[0] <= this.points[1] ? this.points[0] : this.points[1];
const B = this.points[0] > this.points[1] ? this.points[0] : this.points[1];
if(this.video.currentTime >= B) {
this.video.currentTime = A;
}
this.lastTime = timestamp;
}
this.animationFrameId = requestAnimationFrame(checkLoop);
};
this.animationFrameId = requestAnimationFrame(checkLoop);
}
async getTimeLink(time) {
const link = window.location.href.replace(/^(https?:\/\/(www\.)bilibili\.com\/video\/(?:BV|AV)\w+).*/i, '$1') + '?t=' + time;
const success = await Utils.copyText(link);
Utils.showNotification(
success ? '时间标记链接已复制到剪切板' : '复制失败,请手动复制',
success ? '复制成功' : '复制失败'
);
}
}
const createToolbar = async () => {
const video = document.querySelector('#bilibili-player video');
if (!video) return;
const controller = new VideoController(video);
const toolbarbox = document.createElement('div');
toolbarbox.style = 'width: 100%; height: 1.2rem; position: relative; z-index: 100;';
const container = document.querySelector('#playerWrap') || document.querySelector('#player_module');
container.appendChild(toolbarbox);
const toolbarShadow = toolbarbox.attachShadow({mode: 'closed'});
const toolbarStyle = document.createElement('style');
toolbarStyle.innerHTML = UI.createStyle();
toolbarShadow.appendChild(toolbarStyle);
const toolbar = document.createElement('div');
toolbar.id = 'replayer-toolbar';
toolbarShadow.appendChild(toolbar);
const getLinkClass = Utils.isNormalVideo ? '' : ' hide';
Utils.createButton('起点:', 'tool-item tool-text', toolbar);
const pointA = Utils.createButton('起点A', 'tool-item tool-button', toolbar);
const toA = Utils.createButton('跳到这里', 'tool-item tool-button', toolbar);
const linkA = Utils.createButton('复制链接', 'tool-item tool-button' + getLinkClass, toolbar);
Utils.createButton('|', 'tool-item tool-text', toolbar);
Utils.createButton('终点:', 'tool-item tool-text', toolbar);
const pointB = Utils.createButton('终点B', 'tool-item tool-button', toolbar);
const toB = Utils.createButton('跳到这里', 'tool-item tool-button', toolbar);
const linkB = Utils.createButton('复制链接', 'tool-item tool-button' + getLinkClass, toolbar);
Utils.createButton('|', 'tool-item tool-text', toolbar);
const Start = Utils.createButton('开始循环', 'tool-item tool-button', toolbar);
Utils.createButton('|', 'tool-item tool-text' + getLinkClass, toolbar);
Utils.createButton('当前:', 'tool-item tool-text' + getLinkClass, toolbar);
const linkNow = Utils.createButton('复制链接', 'tool-item tool-button' + getLinkClass, toolbar);
controller.pointButtons = [pointA, pointB];
// 加载保存的点位
const savedPoints = [Storage.getPoint(0), Storage.getPoint(1)];
savedPoints.forEach((point, index) => {
if(point !== null) {
controller.points[index] = point;
controller.pointButtons[index].classList.add('active-button');
}
});
// 绑定事件
pointA.addEventListener('click', () => {
const input = prompt('请输入时间点(示例:1h2m30 或 2m30 或 30)\n默认使用当前时间,取消则重置为视频开头', video.currentTime);
controller.setPoint(0, input);
});
pointB.addEventListener('click', () => {
const input = prompt('请输入时间点(示例:1h2m30 或 2m30 或 30)\n默认使用当前时间,取消则重置为视频结尾', video.currentTime);
controller.setPoint(1, input);
});
Start.addEventListener('click', () => controller.startLoop(Start));
toA.addEventListener('click', () => { video.currentTime = controller.points[0]; });
toB.addEventListener('click', () => { video.currentTime = controller.points[1]; });
linkA.addEventListener('click', () => controller.getTimeLink(controller.points[0]));
linkB.addEventListener('click', () => controller.getTimeLink(controller.points[1]));
linkNow.addEventListener('click', () => controller.getTimeLink(video.currentTime));
};
// 等待视频元素加载
const waitForVideo = () => {
let attempts = 0;
const check = setInterval(() => {
if(document.querySelector('#bilibili-player video')) {
clearInterval(check);
createToolbar();
}
attempts++;
if(attempts > 30) {
clearInterval(check);
console.error('未找到视频元素');
}
}, 1000);
};
if (document.readyState === 'complete') {
waitForVideo();
} else {
window.addEventListener('load', waitForVideo);
}
})();