您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
可在Bilibili视频时间轴上选取片段循环播放,支持无限循环及刷新页面后设置持久保存。例如:学习英语时可反复播放某段对话,便于听力练习。
// ==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(); })();