Bilibili Loop Clip

可在Bilibili视频时间轴上选取片段循环播放,支持无限循环及刷新页面后设置持久保存。例如:学习英语时可反复播放某段对话,便于听力练习。

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();