// ==UserScript==
// @name B站循环助手-增强版
// @namespace bilibili-replayer
// @version 1.63
// @description 稳定可靠的AB点循环工具,适配最新B站页面结构
// @author lily
// @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
// @grant GM_deleteValue
// @grant GM_listValues
// ==/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;
}
},
savePreset: (name, points) => {
const prefixedName = name.startsWith('AB_') ? name : `AB_${name}`;
GM_setValue(prefixedName, [...points]);
},
renamePreset: (oldName, newName) => {
// 获取旧预设的数据
const oldPrefixedName = `AB_${oldName}`;
const points = GM_getValue(oldPrefixedName);
if (points) {
// 保存新名称的预设
const newPrefixedName = `AB_${newName}`;
GM_setValue(newPrefixedName, points);
// 删除旧预设
GM_deleteValue(oldPrefixedName);
return true;
}
return false;
},
getAllPresets: () => {
const allKeys = GM_listValues().filter(k => k.startsWith('AB_'));
return allKeys.map(k => ({
name: k.replace('AB_', ''),
value: GM_getValue(k)
}));
},
deletePreset: (name) => GM_deleteValue(`AB_${name}`),
getNextPresetName: () => {
const presets = Storage.getAllPresets();
const existingNumbers = presets
.map(p => parseInt(p.name.replace('AB', '')))
.sort((a, b) => a - b);
// 从1开始查找第一个不存在的数字
let i = 1;
while (existingNumbers.includes(i)) {
i++;
}
return `AB${i}`;
}
};
// 工具函数
const Utils = {
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, duration = 2000) {
// 创建或获取提示容器
let container = document.querySelector('.bilibili-ab-toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'bilibili-ab-toast-container';
document.querySelector('#bilibili-player').appendChild(container);
}
// 创建新的提示
const toast = document.createElement('div');
toast.className = 'bilibili-ab-toast';
toast.textContent = text;
// 添加到容器
container.appendChild(toast);
// 触发动画
setTimeout(() => toast.classList.add('show'), 10);
// 延迟后移除
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, duration);
},
createPresetPanel(controller, saveBtn) {
const panel = document.createElement('div');
panel.className = 'bilibili-ab-preset-panel';
panel.style.cssText = `
position: absolute;
bottom: 25px;
left: 0;
background: rgba(0,0,0,0.8);
border-radius: 4px;
padding: 8px;
min-width: 120px;
display: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
`;
const toolbar = document.querySelector('.ab-loop-toolbar');
toolbar.appendChild(panel);
let isHoveringPanel = false;
panel.addEventListener('mouseenter', () => isHoveringPanel = true);
panel.addEventListener('mouseleave', () => {
isHoveringPanel = false;
panel.style.display = 'none';
});
// 当前选中的预设
let currentPreset = null;
let isRenaming = false;
let renamingPreset = null;
// 使用事件委托处理双击
panel.addEventListener('dblclick', (e) => {
const nameSpan = e.target.closest('.preset-name');
if (!nameSpan || isRenaming) return;
e.stopPropagation();
isRenaming = true;
renamingPreset = nameSpan.dataset.name;
const input = document.createElement('input');
input.type = 'text';
input.value = renamingPreset;
input.className = 'rename-input';
input.style.cssText = `
background: transparent;
border: none;
color: white;
width: 60px;
padding: 0;
font-size: inherit;
outline: none;
border-bottom: 1px solid #00a1d6;
`;
// 替换原有内容
nameSpan.textContent = '';
nameSpan.appendChild(input);
input.focus();
// 处理输入框事件
input.addEventListener('blur', finishRenaming);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
finishRenaming(e);
} else if (e.key === 'Escape') {
isRenaming = false;
updateList();
}
});
});
// 更新预设列表,移除独立的双击事件绑定
const updateList = () => {
panel.innerHTML = Storage.getAllPresets().map(preset => `
<div class="preset-item ${currentPreset === preset.name ? 'active-preset' : ''}"
data-name="${preset.name}">
<span class="preset-name" data-name="${preset.name}">${preset.name}</span>
<span class="delete-btn" data-name="${preset.name}">×</span>
</div>
`).join('');
};
const finishRenaming = (e) => {
if (!isRenaming) return;
const input = e.target;
const newName = input.value.trim();
if (newName && newName !== renamingPreset) {
if (Storage.renamePreset(renamingPreset, newName)) {
if (currentPreset === renamingPreset) {
currentPreset = newName;
}
Utils.showNotification(`已重命名为 ${newName}`);
}
}
isRenaming = false;
renamingPreset = null;
updateList();
};
// 处理预设点击事件
panel.addEventListener('click', (e) => {
const presetItem = e.target.closest('.preset-item');
const deleteBtn = e.target.closest('.delete-btn');
if (deleteBtn) {
const presetName = deleteBtn.dataset.name;
Storage.deletePreset(presetName);
if (currentPreset === presetName) {
currentPreset = null;
controller.resetPoints(); // 重置点位
}
updateList();
Utils.showNotification(`已删除 ${presetName}`);
} else if (presetItem) {
const presetName = presetItem.dataset.name;
const preset = Storage.getAllPresets().find(p => p.name === presetName);
if (currentPreset === presetName) {
// 如果再次点击当前选中的预设,则取消选中
currentPreset = null;
controller.resetPoints(); // 重置点位
} else if (preset) {
// 选中新的预设
currentPreset = presetName;
// 直接使用预设中保存的点位数据
controller.points = [...preset.value];
// 激活AB点按钮
controller.pointButtons.forEach((btn, index) => {
if (preset.value[index] !== null && preset.value[index] !== undefined) {
btn.classList.add('active-button');
} else {
btn.classList.remove('active-button');
}
});
}
updateList();
}
});
// 显示/隐藏面板
saveBtn.addEventListener('mouseenter', () => {
panel.style.display = 'block';
});
saveBtn.addEventListener('mouseleave', (e) => {
setTimeout(() => {
if (!isHoveringPanel) {
panel.style.display = 'none';
}
}, 200);
});
// 初始化更新
updateList();
return { panel, updateList };
}
};
class VideoController {
constructor(video) {
this.video = video;
this.points = [0, video.duration - 1];
this.pointButtons = [];
this.intervalId = null;
}
setPoint(index, value) {
if (this.pointButtons[index].classList.contains('active-button')) {
this.points[index] = index ? this.video.duration - 1 : 0;
this.pointButtons[index].classList.remove('active-button');
Storage.savePoint(index, null);
} else {
this.points[index] = value;
this.pointButtons[index].classList.add('active-button');
Storage.savePoint(index, this.points[index]);
}
}
startLoop(button) {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
button.innerText = '⯈循环';
return;
}
// 确定 A、B 点的正确顺序
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 < A || this.video.currentTime >= B) {
this.video.currentTime = A;
}
button.innerText = '⯀停止';
this.intervalId = setInterval(() => {
if (this.video.currentTime >= B) {
this.video.currentTime = A;
}
}, 200);
}
resetPoints() {
this.points = [0, this.video.duration - 1];
this.pointButtons.forEach(btn => btn.classList.remove('active-button'));
}
}
const createToolbar = () => {
let retryCount = 0;
const maxRetries = 30;
const tryCreate = () => {
const video = document.querySelector('#bilibili-player video');
const controlBar = document.querySelector('.bpx-player-control-bottom');
if (!video || !controlBar) {
retryCount++;
if (retryCount < maxRetries) {
setTimeout(tryCreate, 500);
}
return;
}
const controller = new VideoController(video);
// 创建工具栏容器
const toolbarbox = document.createElement('div');
toolbarbox.className = 'ab-loop-toolbar';
// 设置基础样式
toolbarbox.style.cssText = `
display: flex;
align-items: center;
height: 12px;
background-color: rgba(0, 0, 0, 0.35);
border-radius: 4px;
padding: 0 5px;
box-sizing: border-box;
margin-top: 5px;
`;
// 创建自定义样式
const style = document.createElement('style');
style.textContent = `
.tool-item {
padding: 0 6px;
margin: 0 1px;
height: 12px;
line-height: 12px;
color: #ffffff;
cursor: pointer;
opacity: 0.85;
transition: all 0.2s ease;
border-radius: 2px;
user-select: none;
}
.tool-button:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.1);
}
.active-button {
background-color: #00a1d6 !important;
color: white !important;
opacity: 1 !important;
}
.preset-panel::-webkit-scrollbar {
width: 4px;
background: transparent;
}
.preset-panel::-webkit-scrollbar-thumb {
background: #555;
}
.delete-btn:hover {
color: #ff0000 !important;
}
.ab-loop-toolbar {
position: relative !important;
}
.bilibili-ab-preset-panel {
z-index: 10000 !important;
}
.preset-item {
display: flex;
align-items: center;
padding: 6px 10px;
cursor: pointer;
transition: background 0.2s;
}
.preset-item:hover {
background: rgba(255,255,255,0.05);
}
.delete-btn {
margin-left: auto;
padding-left: 15px;
color: #ff5555;
opacity: 0.7;
}
.delete-btn:hover {
opacity: 1;
}
.active-preset {
background-color: #00a1d6 !important;
color: white !important;
}
.preset-name {
flex-grow: 1;
min-width: 60px;
cursor: pointer;
}
.rename-input {
background: transparent;
border: none;
color: white;
width: 60px;
padding: 0;
font-size: inherit;
outline: none;
border-bottom: 1px solid #00a1d6;
}
.bilibili-ab-toast-container {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 100000;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
pointer-events: none;
}
.bilibili-ab-toast {
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
}
.bilibili-ab-toast.show {
opacity: 1;
transform: translateY(0);
}
`;
document.head.appendChild(style);
// 将工具栏添加到播放栏中
controlBar.appendChild(toolbarbox);
// 创建按钮
const pointA = Utils.createButton('🄰', 'tool-item tool-button', toolbarbox);
const toA = Utils.createButton('跳A', 'tool-item tool-button', toolbarbox);
Utils.createButton('|', 'tool-item tool-text', toolbarbox);
const pointB = Utils.createButton('🄱', 'tool-item tool-button', toolbarbox);
const toB = Utils.createButton('跳B', 'tool-item tool-button', toolbarbox);
Utils.createButton('|', 'tool-item tool-text', toolbarbox);
const Start = Utils.createButton('⯈循环', 'tool-item tool-button', toolbarbox);
const saveBtn = Utils.createButton('存', 'tool-item tool-button', toolbarbox);
const { panel, updateList } = Utils.createPresetPanel(controller, saveBtn);
controller.pointButtons = [pointA, pointB];
// 事件监听
pointA.addEventListener('click', () => {
controller.setPoint(0, video.currentTime);
});
pointB.addEventListener('click', () => {
controller.setPoint(1, video.currentTime);
});
Start.addEventListener('click', () => controller.startLoop(Start));
toA.addEventListener('click', () => { video.currentTime = controller.points[0]; });
toB.addEventListener('click', () => { video.currentTime = controller.points[1]; });
// 修改存储按钮的点击处理逻辑
saveBtn.addEventListener('click', () => {
const newName = Storage.getNextPresetName();
Storage.savePreset(newName, [...controller.points]);
updateList();
Utils.showNotification(`已保存为 ${newName}`);
});
// 处理视频暂停和播放事件
controller.video.addEventListener('pause', () => {
if (controller.intervalId) {
clearInterval(controller.intervalId);
controller.intervalId = null;
Start.innerText = '⯈循环';
}
});
controller.video.addEventListener('play', () => {
if (!controller.intervalId && Start.innerText === '⯀停止') {
controller.startLoop(Start);
}
});
};
tryCreate();
};
// 检查页面加载状态
if (document.readyState === 'complete') {
createToolbar();
} else {
window.addEventListener('load', createToolbar);
}
})();