BilibiliSponsorBlock-Tampermonkey

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

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