🎉 欢迎访问GreasyFork.Org 镜像站!本镜像站由公众号【爱吃馍】搭建,用于分享脚本。联系邮箱📮

Greasy fork 爱吃馍镜像

📂 缓存分发状态(共享加速已生效)
🕒 页面同步时间:2026/01/05 19:52:53
🔄 下次更新时间:2026/01/05 20:52:53
手动刷新缓存

Bilibili Loop Clip

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.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

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