BilibiliSponsorBlock-Tampermonkey

使用 bsbsb.top API 跳过标注片段,并以绿色在进度条上标注广告时段

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @license MIT
// @name         BilibiliSponsorBlock-Tampermonkey
// @namespace    https://github.com/MCfengyou/BilibiliSponsorBlock-Tampermonkey
// @version      1.1
// @description  使用 bsbsb.top API 跳过标注片段,并以绿色在进度条上标注广告时段
// @author       NeoGe_and_GPT-5
// @match        https://www.bilibili.com/video/*
// @match        https://www.bilibili.com/bangumi/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==



(function() {
  'use strict';
  const LOG = (...a)=>console.log('[BSB+ FIX6R7]',...a);
  const API = 'https://bsbsb.top/api/skipSegments?videoID=';

  // state
  let currentBV = null;
  let segments = [];
  let videoEl = null;
  let progressEl = null;
  let markerLayer = null;
  let observer = null;
  let pendingPrompt = null;    // { seg, rule, key }
  let promptTimer = null;

  // control flags
  let manualInSegment = false; // user manually entered this ad segment
  let userSeeking = false;
  let suppressedSegmentKey = null; // the segment key for which prompts are suppressed while inside it
  let lastTime = 0;

  // throttle
  let renderScheduled = false;
  let lastProgressCheck = 0;

  const CATEGORY_RULES = {
    intro:       { label: '过场/开场动画', color: 'rgb(0,255,255)', mode: 'manual' },
    selfpromo:   { label: '无偿/自我推广', color: 'rgb(255,255,0)', mode: 'manual' },
    sponsor:     { label: '赞助/恰饭', color: 'rgb(0,212,0)', mode: 'auto' },
    interaction: { label: '三连/互动提醒', color: 'rgb(204,0,255)', mode: 'manual' },
    preview:     { label: '回顾/概要', color: 'rgb(0,143,214)', mode: 'marker' },
    outro:       { label: '鸣谢/结束画面', color: 'rgb(2,2,237)', mode: 'manual' }
  };

  // ---------- helpers ----------
  function getBV(){ const m = location.href.match(/BV[0-9A-Za-z]+/); return m ? m[0] : null; }

  async function fetchSegments(bv) {
    if (!bv) return [];
    try {
      const res = await fetch(API + bv);
      if (!res.ok) { LOG('segment API status', res.status); return []; }
      const json = await res.json();
      if (!Array.isArray(json)) return [];
      return json.map(item => ({
        start: Number(item.segment?.[0] ?? item.start ?? 0),
        end: Number(item.segment?.[1] ?? item.end ?? 0),
        category: item.category ?? 'sponsor'
      })).filter(s => CATEGORY_RULES[s.category] && isFinite(s.start) && isFinite(s.end) && s.end > s.start);
    } catch (e) {
      LOG('fetchSegments error', e);
      return [];
    }
  }

  function findVideo() {
    const vids = Array.from(document.querySelectorAll('video'));
    for (const v of vids) if (v.offsetParent !== null || v.getClientRects().length) return v;
    return vids[0] || null;
  }

  function findProgress() {
    const candidates = [
      '.bpx-player-progress-wrap',
      '.bpx-player-progress',
      '.bilibili-player-video-progress',
      '.bilibili-player-progress',
      '.bui-progress'
    ];
    for (const s of candidates) {
      const el = document.querySelector(s);
      if (el) return el;
    }
    // fallback near controls
    const ctrl = document.querySelector('.bilibili-player-video-control-bottom') || document.querySelector('.bpx-player-container');
    if (ctrl) {
      const el = ctrl.querySelector('.bpx-player-progress-wrap, .bpx-player-progress, .bilibili-player-video-progress');
      if (el) return el;
    }
    return null;
  }

  function ensureMarkerLayerAttached() {
    const p = findProgress();
    if (!p) return null;
    progressEl = p;
    if (markerLayer && markerLayer.parentElement && markerLayer.parentElement !== progressEl) {
      markerLayer.remove();
      markerLayer = null;
    }
    if (!markerLayer) {
      markerLayer = document.createElement('div');
      markerLayer.className = 'bsb-marker-layer';
      Object.assign(markerLayer.style, {
        position: 'absolute', left: '0', top: '0', width: '100%', height: '100%', pointerEvents: 'none', zIndex: 9
      });
    }
    const cs = getComputedStyle(progressEl);
    if (cs.position === 'static') progressEl.style.position = 'relative';
    if (!progressEl.contains(markerLayer)) progressEl.appendChild(markerLayer);
    return markerLayer;
  }

  function renderMarkers() {
    if (!videoEl) return;
    const p = findProgress();
    if (!p) return;
    ensureMarkerLayerAttached();
    const dur = videoEl.duration || 0;
    if (!dur || !isFinite(dur) || dur <= 0) return;
    const html = segments.map(seg => {
      const rule = CATEGORY_RULES[seg.category];
      if (!rule) return '';
      const left = (seg.start / dur) * 100;
      const width = ((seg.end - seg.start) / dur) * 100;
      return `<div style="position:absolute;left:${left}%;width:${width}%;height:100%;background:${rule.color};opacity:.45;border-radius:2px;"></div>`;
    }).join('');
    markerLayer.innerHTML = html;
    LOG('Markers rendered (count=' + segments.length + ')');
  }

  function scheduleRenderThrottled() {
    if (renderScheduled) return;
    renderScheduled = true;
    setTimeout(() => { try { renderMarkers(); } catch (e) { LOG('renderMarkers e', e); } renderScheduled = false; }, 500);
  }

  function delayedMarkerAttempts(times = 3, interval = 800) {
    for (let i = 1; i <= times; i++) {
      setTimeout(() => scheduleRenderThrottled(), i * interval);
    }
  }

  // ---------- prompt management ----------
  // segKey string
  function segKeyFor(seg){ return `${seg.start}-${seg.end}`; }

  function removePrompt(suppressForCurrentSegment = true) {
    try {
      const el = document.getElementById('bsb-prompt');
      if (el) {
        // fade out
        el.style.transition = 'opacity .18s ease';
        el.style.opacity = '0';
        setTimeout(()=>{ if (el && el.parentElement) el.remove(); }, 200);
      }
      if (promptTimer) { clearInterval(promptTimer); promptTimer = null; }
      if (pendingPrompt && suppressForCurrentSegment) {
        // Suppress further prompts for this segment while user remains inside it
        suppressedSegmentKey = pendingPrompt.key;
        LOG('Suppressed segment', suppressedSegmentKey);
      }
      pendingPrompt = null;
    } catch (e) { LOG('removePrompt error', e); }
  }

  function showManualPrompt(seg, rule) {
    try {
      // if this segment is currently suppressed, do nothing
      const key = segKeyFor(seg);
      if (suppressedSegmentKey === key) return;

      // remove any previous prompt cleanly
      removePrompt(false);

      const div = document.createElement('div');
      div.id = 'bsb-prompt';
      // Ensure pointer events enabled and high z-index
      div.style.cssText = `
        position:fixed;
        right:40px;
        bottom:120px;
        background:rgba(0,0,0,0.78);
        color:#fff;
        padding:10px 14px;
        border-radius:10px;
        font-size:14px;
        z-index:2147483647;
        display:flex;
        align-items:center;
        gap:8px;
        pointer-events:auto;
        user-select:none;
        opacity:0;
      `;

      const span = document.createElement('span');
      span.style.color = rule.color;
      span.innerHTML = `[${rule.label}] | 按 Enter 键跳过 (<span id="bsb-count">5</span>s)`;
      const closeBtn = document.createElement('span');
      closeBtn.id = 'bsb-close';
      closeBtn.textContent = '✕';
      closeBtn.style.cssText = 'cursor:pointer;margin-left:8px;pointer-events:auto;';

      div.appendChild(span);
      div.appendChild(closeBtn);
      document.body.appendChild(div);
      // fade in
      requestAnimationFrame(()=> { div.style.transition='opacity .18s'; div.style.opacity='1'; });

      pendingPrompt = { seg, rule, key };

      // timer ensure single timer; always clear older timer first
      if (promptTimer) { clearInterval(promptTimer); promptTimer = null; }
      let sec = 5;
      const countSpan = div.querySelector('#bsb-count');
      promptTimer = setInterval(() => {
        sec--;
        const cnt = document.getElementById('bsb-count');
        if (cnt) cnt.textContent = sec;
        if (sec <= 0) {
          // when timeout expire, we consider this as "user closed via timeout"
          removePrompt(true);
        }
      }, 1000);

      // close button handling - stop propagation and remove prompt + suppress
      closeBtn.addEventListener('click', (ev) => {
        ev.stopPropagation();
        removePrompt(true);
      }, { passive: true });

    } catch (e) { LOG('showManualPrompt error', e); }
  }

  function showNotice(text, color='rgba(0,212,0,0.92)') {
    try {
      const el = document.createElement('div');
      el.textContent = text;
      Object.assign(el.style, {
        position: 'fixed', right: '28px', bottom: '120px',
        background: color, color: '#fff', padding: '8px 12px',
        borderRadius: '8px', fontSize: '14px', zIndex: 2147483647,
        pointerEvents: 'none', opacity: '0', transition: 'opacity .2s'
      });
      const target = document.fullscreenElement || document.body;
      target.appendChild(el);
      requestAnimationFrame(()=> el.style.opacity='1');
      setTimeout(()=> el.style.opacity='0', 1600);
      setTimeout(()=> el.remove(), 2000);
    } catch (e) { LOG('showNotice error', e); }
  }

  // ---------- key handling: Enter skip, Delete close ----------
  if (!window.__bsb_keys_attached) {
    window.addEventListener('keydown', (ev) => {
      try {
        if (!pendingPrompt) return;
        if (ev.key === 'Enter') {
          // perform skip
          if (videoEl && pendingPrompt) {
            videoEl.currentTime = Math.min(pendingPrompt.seg.end + 0.05, videoEl.duration || pendingPrompt.seg.end + 0.05);
            showNotice(`${pendingPrompt.rule.label} 已跳过`, pendingPrompt.rule.color);
            removePrompt(true); // suppress while in segment
          }
        } else if (ev.key === 'Delete' || ev.key === 'Backspace') {
          // close prompt without skipping, but suppress while inside
          removePrompt(true);
          showNotice('已关闭提示', 'rgba(120,120,120,0.9)');
        }
      } catch (e) { LOG('keydown handler error', e); }
    }, { passive: true });
    window.__bsb_keys_attached = true;
  }

  // ---------- playback handlers ----------
  function inSegmentAtTime(t) {
    return segments.find(s => t >= s.start && t < s.end);
  }

  function onTimeUpdate() {
    if (!videoEl) return;
    const t = videoEl.currentTime;
    const seg = inSegmentAtTime(t);

    // if left any previously suppressed segment, clear suppression
    if (!seg) {
      if (suppressedSegmentKey) {
        // user moved out of suppressed segment - reset suppression
        suppressedSegmentKey = null;
        LOG('Cleared suppressedSegmentKey (left segment)');
      }
      manualInSegment = false;
      lastTime = t;
      return;
    }

    // If we are inside a segment
    const rule = CATEGORY_RULES[seg.category];
    if (!rule) { lastTime = t; return; }

    // If the current segment is the suppressed one, do nothing (no prompts)
    const key = segKeyFor(seg);
    if (suppressedSegmentKey === key) { lastTime = t; return; }

    // Determine naturalPlay: small positive delta and not currently seeking and not known manualInSegment
    const delta = t - lastTime;
    const naturalPlay = delta > 0 && delta < 2 && !userSeeking && !manualInSegment;
    lastTime = t;

    if (rule.mode === 'auto') {
      if (naturalPlay) {
        try {
          videoEl.currentTime = Math.min(seg.end + 0.05, videoEl.duration || seg.end + 0.05);
          showNotice(`${rule.label} 已跳过`, rule.color);
        } catch (e) { LOG('auto skip failed', e); }
      } else {
        // user manually seeked into it -> do nothing (allow watching)
      }
    } else if (rule.mode === 'manual') {
      // show prompt only on naturalPlay OR if user arrived exactly at seg.start (special case)
      // But we must avoid prompting repeatedly: only show if not pendingPrompt and not suppressed
      if (!pendingPrompt && naturalPlay) {
        showManualPrompt(seg, rule);
      }
      // also: if user just seeked exactly to segment start (we detect in seeking handler and set manualInSegment accordingly),
      // we want to show prompt when they re-enter segment HEAD (requirement #4). We'll handle that in seeking handler.
    }
  }

  function onSeeking() {
    userSeeking = true;
    try {
      const t = videoEl.currentTime;
      const seg = inSegmentAtTime(t);
      if (seg) {
        // user manually entered the segment -> set manualInSegment true and do NOT show prompt instantly
        manualInSegment = true;
        // Do NOT set suppressedSegmentKey yet — we only suppress after user actively closes prompt.
        LOG('User seeking: manualInSegment set for segment', segKeyFor(seg));
      }
    } catch (e) { LOG('onSeeking error', e); }
  }

  function onSeeked() {
    // If user seeked to just before the segment start (within small tolerance) -- treat as "re-enter at segment head"
    try {
      const t = videoEl.currentTime;
      const seg = inSegmentAtTime(t);
      if (seg) {
        const tolerance = 0.35; // seconds tolerance for "segment head"
        if (Math.abs(t - seg.start) < tolerance) {
          // reset suppression for this segment so prompt will appear (requirement 4)
          if (suppressedSegmentKey === segKeyFor(seg)) {
            suppressedSegmentKey = null;
            LOG('User jumped to segment head -> cleared suppression for', segKeyFor(seg));
          }
          // Show prompt since user explicitly jumped to segment head (but only if not already pending)
          if (!pendingPrompt && CATEGORY_RULES[seg.category].mode === 'manual') {
            // show prompt after a tiny delay so timeupdate doesn't race
            setTimeout(()=>{ if (!pendingPrompt) showManualPrompt(seg, CATEGORY_RULES[seg.category]); }, 60);
          }
        } else {
          // user seeked somewhere inside the segment - mark manualInSegment so we don't auto-skip
          manualInSegment = true;
        }
      } else {
        // not in segment
        manualInSegment = false;
      }
    } catch (e) { LOG('onSeeked error', e); }
    // clear seeking flag after short delay
    setTimeout(()=> { userSeeking = false; }, 700);
  }

  function attachVideoHandlers(v) {
    if (!v) return;
    if (videoEl && videoEl !== v) {
      try {
        videoEl.removeEventListener('timeupdate', onTimeUpdate);
        videoEl.removeEventListener('seeking', onSeeking);
        videoEl.removeEventListener('seeked', onSeeked);
        videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
      } catch (e) { /* ignore */ }
    }
    videoEl = v;
    videoEl.addEventListener('timeupdate', onTimeUpdate, { passive: true });
    videoEl.addEventListener('seeking', onSeeking);
    videoEl.addEventListener('seeked', onSeeked);
    videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
    LOG('Attached video handlers.');
  }

  function onLoadedMetadata() {
    delayedMarkerAttempts(3, 700);
  }

  // progress observer
  function attachProgressObserver() {
    try {
      if (observer) { observer.disconnect(); observer = null; }
      const target = findProgress();
      if (!target) return;
      observer = new MutationObserver(()=> scheduleRenderThrottled());
      observer.observe(target, { childList: true, subtree: true });
      LOG('Progress observer attached.');
    } catch (e) { LOG('attachProgressObserver error', e); }
  }

  // pollers to detect replacements
  function checkVideoChange() {
    try {
      const v = findVideo();
      if (v && v !== videoEl) {
        LOG('Detected video element change -> reattach');
        attachVideoHandlers(v);
        scheduleRenderThrottled();
        delayedMarkerAttempts(3,700);
        attachProgressObserver();
      }
    } catch (e) { LOG('checkVideoChange error', e); }
  }

  function checkProgressAndMarkers() {
    const now = Date.now();
    if (now - lastProgressCheck < 900) return;
    lastProgressCheck = now;
    try {
      if (!videoEl) {
        const v = findVideo();
        if (v) attachVideoHandlers(v);
      }
      if (!videoEl || !segments.length) return;
      const p = findProgress();
      if (!p) { delayedMarkerAttempts(3,800); return; }
      if (!p.contains(markerLayer) || !markerLayer) {
        LOG('Marker layer missing -> reattach');
        ensureMarkerLayerAttached();
        scheduleRenderThrottled();
        delayedMarkerAttempts(3,700);
      } else {
        if (markerLayer && markerLayer.children.length === 0 && segments.length > 0) {
          scheduleRenderThrottled();
        }
      }
      if (!observer) attachProgressObserver();
    } catch (e) { LOG('checkProgressAndMarkers error', e); }
  }

  // init for BV
  async function initializeForBV() {
    const bv = getBV(); if (!bv) return;
    if (bv === currentBV) return;
    currentBV = bv;
    LOG('Initialize for', bv);
    // reset state
    suppressedSegmentKey = null;
    manualInSegment = false;
    userSeeking = false;
    pendingPrompt && removePrompt(false);

    segments = await fetchSegments(bv);
    LOG('Segments loaded', segments.length);

    // attach video handlers if present
    const v = findVideo();
    if (v) attachVideoHandlers(v);
    scheduleRenderThrottled();
    delayedMarkerAttempts(3, 700);
    attachProgressObserver();
  }

  // SPA detection + pollers
  let lastHref = location.href;
  setInterval(() => {
    if (location.href !== lastHref) {
      lastHref = location.href;
      LOG('URL changed -> schedule init');
      setTimeout(initializeForBV, 700);
    }
    try { checkVideoChange(); checkProgressAndMarkers(); } catch (e) {}
  }, 800);

  // light global observer to detect heavy DOM churn
  const globalObserver = new MutationObserver((muts) => {
    if (muts.length > 8) setTimeout(initializeForBV, 900);
  });
  globalObserver.observe(document.body, { childList: true, subtree: true });

  // start
  setTimeout(initializeForBV, 1200);

  // Expose debug helper
  window.__bsb_debug_state = () => ({
    currentBV, segmentsCount: segments.length, videoExists: !!videoEl, progressExists: !!findProgress(), markerExists: !!markerLayer, suppressedSegmentKey
  });

  LOG('BSB+ FIX6R7 loaded.');
})();