// ==UserScript==
// @name:zh-CN Bilibili 循环片段
// @name Bilibili Loop Clip
// @name:zh-TW Bilibili 循環片段
// @name:en Bilibili Loop Clip
// @namespace https://github.com/ooking/bilibili-loop-clip
// @version 1.0.0
// @description 可在Bilibili视频时间轴上选取片段循环播放,支持无限循环及刷新页面后设置持久保存。例如:学习英语时可反复播放某段对话,便于听力练习。
// @description:zh-CN 可在Bilibili视频时间轴上选取片段循环播放,支持无限循环及刷新页面后设置持久保存。例如:学习英语时可反复播放某段对话,便于听力练习。
// @description:zh-TW 可在Bilibili影片時間軸上選取片段循環播放,支援無限循環且刷新頁面後設定持久保存。例如:學習英語時可反覆播放某段對話,便於聽力練習。
// @description:en Select and loop a clip on the Bilibili timeline, supports infinite loop and persistent settings after refresh. For example: repeatedly play a dialogue for English listening practice.
// @author King Chan ([email protected])
// @include *://www.bilibili.com/video/*
// @icon https://www.bilibili.com/favicon.ico
// @license MPL-2.0
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
(function () {
'use strict';
function formatTime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return [h, m, s]
.map((v) => v < 10 ? '0' + v : v)
.join(':');
}
function parseTime(str) {
const parts = str.split(':').map(Number);
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
} else if (parts.length === 2) {
return parts[0] * 60 + parts[1];
} else if (parts.length === 1) {
return parts[0];
}
return 0;
}
// 获取当前Bilibili视频bvid
function getBvid() {
const match = window.location.pathname.match(/\/video\/(BV[\w]+)/);
return match ? match[1] : null;
}
function getPlayer() {
return document.querySelector('video');
}
function saveSettings(settings) {
const bvid = getBvid();
if (bvid) {
GM_setValue('bl_loop_' + bvid, JSON.stringify(settings));
}
}
function loadSettings() {
const bvid = getBvid();
if (bvid) {
const data = GM_getValue('bl_loop_' + bvid, null);
return data ? JSON.parse(data) : null;
}
return null;
}
function createLoopButton(onClick) {
const btn = document.createElement('div');
btn.className = 'bpx-player-ctrl-btn bl-loop-btn';
btn.setAttribute('role', 'button');
btn.setAttribute('tabindex', '0');
btn.setAttribute('aria-label', '循环片段');
btn.style.display = 'inline-flex';
btn.style.alignItems = 'center';
btn.style.justifyContent = 'center';
btn.style.margin = '0 4px';
btn.style.cursor = 'pointer';
btn.style.width = 'auto'; // 去除原生 class 的宽度限制
btn.style.minWidth = 'unset';
btn.style.maxWidth = 'unset';
btn.onclick = onClick;
// 模仿原生按钮,仅显示文字
const textSpan = document.createElement('span');
textSpan.textContent = '循环片段';
textSpan.style.color = '#00a1d6'; // 蓝色文字
textSpan.style.fontSize = '12px';
textSpan.style.fontWeight = 'bold';
textSpan.style.padding = '0 8px';
textSpan.style.lineHeight = '24px';
btn.appendChild(textSpan);
return btn;
}
function showLoopDialog(settings, player, onSave) {
if (document.getElementById('bl-loop-dialog')) return;
if (player) player.pause();
const btn = document.querySelector('.bl-loop-btn');
const btnText = btn ? btn.querySelector('span') : null;
let top = 80, left = window.innerWidth / 2;
if (btn) {
const rect = btn.getBoundingClientRect();
top = rect.top - 16 - 240;
if (top < 10) top = 10;
left = rect.left + rect.width / 2;
}
const dialog = document.createElement('div');
dialog.id = 'bl-loop-dialog';
dialog.style.position = 'fixed';
dialog.style.top = top + 'px';
dialog.style.left = left + 'px';
dialog.style.transform = 'translateX(-50%)';
dialog.style.background = 'linear-gradient(135deg, #fffbe6 0%, #f7f7fa 10%)';
dialog.style.padding = '8px 28px 20px 28px';
dialog.style.borderRadius = '16px';
dialog.style.boxShadow = '0 4px 24px rgba(0,0,0,0.13)';
dialog.style.minWidth = '250px';
dialog.style.zIndex = '99999';
dialog.style.fontFamily = 'Segoe UI, Arial, sans-serif';
dialog.style.color = '#222';
dialog.style.cursor = 'move';
const titleBar = document.createElement('div');
titleBar.style.fontWeight = 'bold';
titleBar.style.marginBottom = '18px';
titleBar.style.fontSize = '14px';
titleBar.style.letterSpacing = '0.5px';
titleBar.style.textAlign = 'center';
titleBar.textContent = '循环片段设置';
dialog.appendChild(titleBar);
const labelStyle = 'display:inline-block;min-width:110px;font-size:12px;margin-bottom:8px;';
const inputStyle = 'font-size:12px;padding:2px 8px;border-radius:6px;border:1px solid #ccc;margin-right:8px;width:60px;background:#fff;';
const btnStyle = 'font-size:12px;padding:2px 10px;border-radius:8px;border:1px solid #bbb;background:#00a1d6;color:#fff;cursor:pointer;margin-left:8px;';
const labelStart = document.createElement('label');
labelStart.setAttribute('style', labelStyle);
labelStart.textContent = '开始时间:';
const inputStart = document.createElement('input');
inputStart.id = 'bl-loop-start';
inputStart.type = 'text';
inputStart.value = formatTime(settings.start);
inputStart.setAttribute('style', inputStyle);
labelStart.appendChild(inputStart);
const btnGetStart = document.createElement('button');
btnGetStart.textContent = '获取当前';
btnGetStart.setAttribute('style', btnStyle);
btnGetStart.onclick = () => {
inputStart.value = formatTime(Math.floor(player.currentTime));
};
labelStart.appendChild(btnGetStart);
dialog.appendChild(labelStart);
dialog.appendChild(document.createElement('br'));
const labelEnd = document.createElement('label');
labelEnd.setAttribute('style', labelStyle);
labelEnd.textContent = '结束时间:';
const inputEnd = document.createElement('input');
inputEnd.id = 'bl-loop-end';
inputEnd.type = 'text';
inputEnd.value = formatTime(settings.end);
inputEnd.setAttribute('style', inputStyle);
labelEnd.appendChild(inputEnd);
const btnGetEnd = document.createElement('button');
btnGetEnd.textContent = '获取当前';
btnGetEnd.setAttribute('style', btnStyle);
btnGetEnd.onclick = () => {
inputEnd.value = formatTime(Math.floor(player.currentTime));
};
labelEnd.appendChild(btnGetEnd);
dialog.appendChild(labelEnd);
dialog.appendChild(document.createElement('br'));
const labelCount = document.createElement('label');
labelCount.setAttribute('style', labelStyle);
labelCount.textContent = '循环次数:';
const inputCount = document.createElement('input');
inputCount.id = 'bl-loop-count';
inputCount.type = 'number';
inputCount.min = '1';
inputCount.value = settings.count || '';
inputCount.setAttribute('style', inputStyle);
labelCount.appendChild(inputCount);
const spanInfinite = document.createElement('span');
spanInfinite.setAttribute('style', 'margin-left:12px;font-size:12px;');
// 无限循环默认选中
const inputInfinite = document.createElement('input');
inputInfinite.id = 'bl-loop-infinite';
inputInfinite.type = 'checkbox';
inputInfinite.checked = true;
spanInfinite.appendChild(inputInfinite);
spanInfinite.appendChild(document.createTextNode(' 无限循环'));
labelCount.appendChild(spanInfinite);
// 默认选中时禁用循环次数输入框
inputCount.disabled = inputInfinite.checked;
dialog.appendChild(labelCount);
dialog.appendChild(document.createElement('br'));
let isLoopPlaying = false;
const btnLoopPlay = document.createElement('button');
btnLoopPlay.textContent = '播放';
btnLoopPlay.setAttribute('style', btnStyle + 'margin-right:8px;background:#7ed957;border:1px solid #6bbf4e;');
const btnLoopPause = document.createElement('button');
btnLoopPause.textContent = '停止';
btnLoopPause.setAttribute('style', btnStyle + 'margin-right:8px;background:#ffb4b4;border:1px solid #e88c8c;');
btnLoopPause.disabled = true;
dialog.appendChild(btnLoopPlay);
dialog.appendChild(btnLoopPause);
// 保存按钮
const btnSave = document.createElement('button');
btnSave.id = 'bl-loop-save';
btnSave.textContent = '保存';
btnSave.setAttribute('style', btnStyle + 'margin-right:8px;background:#00a1d6;color:#fff;border:1px solid #00a1d6;');
dialog.appendChild(btnSave);
// 取消按钮
const btnCancel = document.createElement('button');
btnCancel.id = 'bl-loop-cancel';
btnCancel.textContent = '关闭';
btnCancel.setAttribute('style', btnStyle + 'background:#00a1d6;color:#fff;border:1px solid #00a1d6;');
dialog.appendChild(btnCancel);
document.body.appendChild(dialog);
let isDragging = false, offsetX = 0, offsetY = 0;
dialog.addEventListener('mousedown', function(e) {
if (e.button !== 0) return;
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT' || e.target.tagName === 'LABEL') return;
isDragging = true;
offsetX = e.clientX - dialog.getBoundingClientRect().left;
offsetY = e.clientY - dialog.getBoundingClientRect().top;
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
document.body.style.userSelect = 'none';
});
function moveHandler(e) {
if (isDragging) {
dialog.style.left = e.clientX - offsetX + 'px';
dialog.style.top = e.clientY - offsetY + 'px';
dialog.style.transform = '';
}
}
function upHandler() {
isDragging = false;
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
document.body.style.userSelect = '';
}
btnSave.onclick = () => {
const start = parseTime(inputStart.value);
const end = parseTime(inputEnd.value);
const infinite = inputInfinite.checked;
const count = infinite ? 0 : parseInt(inputCount.value, 10) || 1;
onSave({ start, end, count, infinite });
};
btnCancel.onclick = () => {
document.body.removeChild(dialog);
// stopLoopPlay();
};
inputInfinite.onchange = (e) => {
inputCount.disabled = e.target.checked;
};
let loopCount = 0;
let loopHandler = null;
function startLoopPlay() {
if (isLoopPlaying) return;
isLoopPlaying = true;
btnLoopPlay.disabled = true;
btnLoopPause.disabled = false;
loopCount = 0;
player.currentTime = parseTime(inputStart.value);
player.play();
// 按钮文字变绿色
if (btnText) btnText.style.color = '#43d15d';
loopHandler = function() {
const start = parseTime(inputStart.value);
const end = parseTime(inputEnd.value);
const infinite = inputInfinite.checked;
const count = infinite ? 0 : parseInt(inputCount.value, 10) || 1;
if (start < end && player.currentTime >= end) {
if (infinite || loopCount < count - 1) {
player.currentTime = start;
player.play();
loopCount++;
} else {
loopCount = 0;
player.pause();
stopLoopPlay();
}
}
if (player.currentTime < start || player.currentTime > end) {
loopCount = 0;
}
};
player.addEventListener('timeupdate', loopHandler);
}
function stopLoopPlay() {
if (!isLoopPlaying) return;
isLoopPlaying = false;
btnLoopPlay.disabled = false;
btnLoopPause.disabled = true;
if (loopHandler) player.removeEventListener('timeupdate', loopHandler);
loopHandler = null;
player.pause();
// 恢复按钮文字颜色
if (btnText) btnText.style.color = '#00a1d6';
}
btnLoopPlay.onclick = startLoopPlay;
btnLoopPause.onclick = stopLoopPlay;
}
function main() {
let lastUrl = '';
setInterval(() => {
if (window.location.href !== lastUrl) {
lastUrl = window.location.href;
setTimeout(init, 1000);
}
}, 1000);
function init() {
document.querySelectorAll('.bl-loop-btn').forEach((el) => el.remove());
const player = getPlayer();
if (!player) return;
// 优先插入到底部右侧控制容器
let bottomRight = document.querySelector('.bpx-player-control-bottom-right');
let sendBtn = document.querySelector('.bui-area.bui-button-blue');
let dmBtn = document.querySelector('.bpx-player-dm-btn');
let controls = null;
if (bottomRight) {
controls = bottomRight;
} else if (sendBtn && sendBtn.parentNode) {
controls = sendBtn.parentNode;
} else if (dmBtn && dmBtn.parentNode) {
controls = dmBtn.parentNode;
} else {
controls = document.querySelector('.bpx-player-control-left')
|| document.querySelector('.bilibili-player-video-control-left')
|| document.querySelector('.bilibili-player-video-control-bottom')
|| document.querySelector('.bpx-player-control-bar')
|| document.body;
}
let settings = loadSettings() || { start: 0, end: Math.floor(player.duration), count: 1, infinite: false };
const btnLoop = createLoopButton(() => {
showLoopDialog(settings, player, (newSettings) => {
settings = { ...settings, ...newSettings };
saveSettings(settings);
});
});
if (bottomRight) {
controls.appendChild(btnLoop);
} else if (sendBtn && sendBtn.parentNode) {
sendBtn.parentNode.insertBefore(btnLoop, sendBtn.nextSibling);
} else if (dmBtn && dmBtn.parentNode) {
dmBtn.parentNode.insertBefore(btnLoop, dmBtn.nextSibling);
} else {
controls.appendChild(btnLoop);
}
}
setTimeout(init, 1000);
}
main();
})();