基於 SponsorBlock API 自動跳過 YouTube 和 Bilibili 影片中的贊助片段
// ==UserScript==
// @name SponsorBlock Lite
// @name:en SponsorBlock Lite - Auto-skip sponsor segments on YouTube and Bilibili
// @name:zh-CN SponsorBlock Lite - 自动跳过 YouTube/Bilibili 赞助内容
// @name:zh-TW SponsorBlock Lite - 自動跳過 YouTube/Bilibili 贊助內容
// @name:ja SponsorBlock Lite - YouTube/Bilibili スポンサー自動スキップ
// @name:ko SponsorBlock Lite - YouTube/Bilibili 스폰서 자동 건너뛰기
// @name:de SponsorBlock Lite - YouTube/Bilibili Sponsoren überspringen
// @name:fr SponsorBlock Lite - Ignorer les sponsors YouTube/Bilibili
// @name:es SponsorBlock Lite - Saltar patrocinadores YouTube/Bilibili
// @name:it SponsorBlock Lite - Salta sponsor YouTube/Bilibili
// @namespace https://github.com/hxueh
// @version 1.1.1
// @description Auto-skip sponsor segments on YouTube and Bilibili using SponsorBlock API
// @description:en Auto-skip sponsor segments on YouTube and Bilibili using SponsorBlock API
// @description:zh-CN 基于 SponsorBlock API 自动跳过 YouTube 和 Bilibili 视频中的赞助片段
// @description:zh-TW 基於 SponsorBlock API 自動跳過 YouTube 和 Bilibili 影片中的贊助片段
// @description:ja SponsorBlock API を使用して YouTube と Bilibili 動画のスポンサーセグメントを自動的にスキップします
// @description:ko SponsorBlock API를 사용하여 YouTube 및 Bilibili 동영상의 스폰서 구간을 자동으로 건너뜁니다
// @description:de Überspringen Sie Sponsorensegmente in YouTube- und Bilibili-Videos automatisch mit der SponsorBlock-API
// @description:fr Ignorez automatiquement les segments sponsorisés dans les vidéos YouTube et Bilibili via l'API SponsorBlock
// @description:es Salte automáticamente los segmentos de patrocinadores en videos de YouTube y Bilibili usando la API de SponsorBlock
// @description:it Salta automaticamente i segmenti degli sponsor nei video di YouTube e Bilibili utilizzando l'API SponsorBlock
// @author hxueh
// @match https://www.youtube.com/*
// @match https://music.youtube.com/*
// @match https://m.youtube.com/*
// @match https://*.bilibili.com/video/*
// @icon https://sponsor.ajay.app/LogoSponsorBlock256px.png
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect sponsor.ajay.app
// @connect bsbsb.top
// @run-at document-idle
// @license LGPL-3.0-or-later
// ==/UserScript==
(function () {
"use strict";
// ==================== CONSTANTS ====================
// Platform detection (must be first for other constants to use)
const IS_BILIBILI = window.location.hostname.includes("bilibili.com");
const API_BASE_YOUTUBE = "https://sponsor.ajay.app";
const API_BASE_BILIBILI = "https://bsbsb.top";
const API_BASE = IS_BILIBILI ? API_BASE_BILIBILI : API_BASE_YOUTUBE;
const CATEGORIES = [
"sponsor",
"selfpromo",
"exclusive_access",
"interaction",
"outro",
"music_offtopic",
];
const ACTION_TYPES = ["skip", "full"];
const SKIP_BUFFER = 0.003;
// Colors for all categories (used in preview bar and category pill)
const CATEGORY_COLORS = {
sponsor: "#00d400",
selfpromo: "#ffff00",
exclusive_access: "#008a5c",
interaction: "#cc00ff",
outro: "#0202ed",
music_offtopic: "#ff9900",
};
const CATEGORY_LABELS = {
exclusive_access: "Exclusive Access",
music_offtopic: "Music: Non-Music",
};
// ==================== STATE ====================
let currentVideoID = null;
let segments = [];
let skippableSegments = [];
let skipScheduleTimer = null;
let video = null;
let lastSkippedUUID = null;
let currentSegmentIndex = 0;
let videoChangeDebounce = null;
let previewBarContainer = null;
let videoDuration = 0;
let lastUrl = location.href;
let urlPollInterval = null;
let videoObserver = null;
let rafSkipId = null; // For requestAnimationFrame-based skipping
let lastVideoSrc = null; // Track video element replacement
// Platform detection
const IS_MUSIC_YOUTUBE = window.location.hostname === "music.youtube.com";
const IS_MOBILE_YOUTUBE = window.location.hostname === "m.youtube.com";
// Vinegar detection - now a function that's called when needed
let IS_VINEGAR = false;
function updateVinegarDetection() {
const hasVideo = document.querySelector("video") !== null;
const hasYouTubePlayer = document.querySelector("#movie_player, ytm-player, #player") !== null;
const hasYouTubeProgressBar = document.querySelector(".ytp-progress-bar, .progress-bar-line") !== null;
// Vinegar: video exists but no YouTube player components
const detected = hasVideo && !hasYouTubePlayer && !hasYouTubeProgressBar;
if (detected && !IS_VINEGAR) {
IS_VINEGAR = true;
log("Vinegar/native video mode detected");
}
return IS_VINEGAR;
}
// ==================== CSS INJECTION ====================
function injectStyles() {
const css = `
/* Desktop YouTube styles */
#sb-lite-previewbar {
position: absolute;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
overflow: visible;
pointer-events: none;
z-index: 42;
list-style: none;
transform: scaleY(0.6);
transition: transform 0.1s cubic-bezier(0, 0, 0.2, 1);
}
/* Expand on hover (desktop) */
.ytp-progress-bar:hover #sb-lite-previewbar {
transform: scaleY(1);
}
/* Fullscreen mode (desktop) */
.ytp-big-mode #sb-lite-previewbar {
transform: scaleY(0.625);
}
.ytp-big-mode .ytp-progress-bar:hover #sb-lite-previewbar {
transform: scaleY(1);
}
/* Mobile YouTube styles */
.advancement-bar-line #sb-lite-previewbar,
.advancement-bar #sb-lite-previewbar,
.progress-bar-line #sb-lite-previewbar {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
padding: 0;
margin: 0;
overflow: visible;
pointer-events: none;
z-index: 42;
list-style: none;
transform: none;
}
.sb-lite-segment {
position: absolute;
height: 100%;
min-width: 1px;
display: inline-block;
opacity: 0.7;
}
.sb-lite-segment:hover {
opacity: 1;
}
#sb-lite-category-pill {
display: none;
align-items: center;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
margin-left: 8px;
color: white;
font-family: Roboto, Arial, sans-serif;
white-space: nowrap;
cursor: default;
user-select: none;
}
/* Mobile category pill adjustments */
.ytm-slim-owner-container #sb-lite-category-pill,
ytm-slim-owner-renderer #sb-lite-category-pill {
font-size: 10px;
padding: 2px 8px;
margin-left: 4px;
}
/* Bilibili styles */
.bpx-player-progress-wrap #sb-lite-previewbar,
.bpx-player-progress #sb-lite-previewbar,
.bilibili-player-video-progress #sb-lite-previewbar,
.squirtle-progress-wrap #sb-lite-previewbar {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
padding: 0;
margin: 0;
overflow: visible;
pointer-events: none;
z-index: 42;
list-style: none;
transform: none;
}
/* Bilibili category pill adjustments */
.video-title #sb-lite-category-pill,
.video-info-title #sb-lite-category-pill {
font-size: 12px;
padding: 2px 8px;
margin-left: 8px;
}
`;
if (typeof GM_addStyle !== "undefined") {
GM_addStyle(css);
} else {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
}
}
// ==================== UTILITY FUNCTIONS ====================
async function sha256(message) {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function getHashPrefix(videoID) {
const hash = await sha256(videoID);
return hash.slice(0, 4);
}
function getBilibiliVideoID() {
const url = window.location.href;
const patterns = [
/\/video\/([^/?#]+)/,
/\/BV([^/?#]+)/,
/bvid=([^&]+)/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) {
let videoId = match[1];
if (!videoId.startsWith("BV")) {
videoId = "BV" + videoId;
}
return videoId;
}
}
return null;
}
function getVideoID() {
if (IS_BILIBILI) {
return getBilibiliVideoID();
}
const url = new URL(window.location.href);
const vParam = url.searchParams.get("v");
if (vParam && /^[a-zA-Z0-9_-]{11}$/.test(vParam)) {
return vParam;
}
const shortsMatch = url.pathname.match(/\/shorts\/([a-zA-Z0-9_-]{11})/);
if (shortsMatch) return shortsMatch[1];
const embedMatch = url.pathname.match(/\/embed\/([a-zA-Z0-9_-]{11})/);
if (embedMatch) return embedMatch[1];
const liveMatch = url.pathname.match(/\/live\/([a-zA-Z0-9_-]{11})/);
if (liveMatch) return liveMatch[1];
// Mobile watch URL pattern
const watchMatch = url.pathname.match(/\/watch\/([a-zA-Z0-9_-]{11})/);
if (watchMatch) return watchMatch[1];
return null;
}
function getVideoDuration() {
return video?.duration || 0;
}
function log(message, ...args) {
console.log(
`[SB Lite${IS_BILIBILI ? " Bilibili" : IS_VINEGAR ? " Vinegar" : IS_MOBILE_YOUTUBE ? " Mobile" : ""}]`,
message,
...args,
);
}
function logError(message, ...args) {
console.error(
`[SB Lite${IS_BILIBILI ? " Bilibili" : IS_VINEGAR ? " Vinegar" : IS_MOBILE_YOUTUBE ? " Mobile" : ""}]`,
message,
...args,
);
}
// ==================== API FUNCTIONS ====================
function fetchSegments(videoID) {
return new Promise(async (resolve) => {
try {
const hashPrefix = await getHashPrefix(videoID);
const params = new URLSearchParams({
categories: JSON.stringify(CATEGORIES),
actionTypes: JSON.stringify(ACTION_TYPES),
});
GM_xmlhttpRequest({
method: "GET",
url: `${API_BASE}/api/skipSegments/${hashPrefix}?${params}`,
headers: { Accept: "application/json" },
onload(response) {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
const videoData = data.find((v) => v.videoID === videoID);
const segs = videoData?.segments || [];
segs.sort((a, b) => a.segment[0] - b.segment[0]);
resolve(segs);
} catch {
resolve([]);
}
} else {
resolve([]);
}
},
onerror() {
resolve([]);
},
});
} catch {
resolve([]);
}
});
}
// ==================== SKIP LOGIC ====================
function computeSkippableSegments() {
skippableSegments = segments.filter((s) => {
if (s.actionType === "full") return false;
if (s.category === "music_offtopic" && !IS_MUSIC_YOUTUBE) return false;
return true;
});
currentSegmentIndex = 0;
}
function skipToTime(targetTime, retryCount = 0) {
if (!video || targetTime === undefined) return false;
const maxRetries = 3;
const previousTime = video.currentTime;
try {
video.currentTime = targetTime;
// On iOS/Vinegar, verify the skip worked after a short delay
if (IS_VINEGAR && retryCount < maxRetries) {
setTimeout(() => {
// Check if we're still in a segment that should be skipped
// (meaning the skip might have failed)
const currentTime = video.currentTime;
const timeDiff = Math.abs(currentTime - targetTime);
// If we're more than 0.5s away from target and still before target,
// the skip likely failed
if (timeDiff > 0.5 && currentTime < targetTime - 0.5) {
log(`Skip verification failed (attempt ${retryCount + 1}), retrying...`);
skipToTime(targetTime, retryCount + 1);
}
}, 100);
}
return true;
} catch (e) {
logError("Skip failed:", e);
return false;
}
}
function findNextSegment(currentTime) {
if (
currentSegmentIndex > 0 &&
skippableSegments[currentSegmentIndex - 1] &&
currentTime < skippableSegments[currentSegmentIndex - 1].segment[0]
) {
currentSegmentIndex = 0;
}
while (currentSegmentIndex < skippableSegments.length) {
const seg = skippableSegments[currentSegmentIndex];
if (currentTime < seg.segment[1] - SKIP_BUFFER) {
return { segment: seg, index: currentSegmentIndex };
}
currentSegmentIndex++;
}
return null;
}
// RAF-based skip loop for Vinegar (more responsive than timeupdate)
function startRAFSkipLoop() {
if (rafSkipId) {
cancelAnimationFrame(rafSkipId);
}
function checkAndSkip() {
if (!video || !skippableSegments.length) {
rafSkipId = null;
return;
}
// Check for video element replacement
if (IS_VINEGAR) {
const currentVideo = document.querySelector("video");
if (currentVideo && currentVideo !== video) {
log("Video element replaced, re-attaching...");
video = currentVideo;
setupVideoListeners();
}
}
if (!video.paused) {
const currentTime = video.currentTime;
for (const seg of skippableSegments) {
const [startTime, endTime] = seg.segment;
if (
currentTime >= startTime - SKIP_BUFFER &&
currentTime < endTime - SKIP_BUFFER &&
lastSkippedUUID !== seg.UUID
) {
lastSkippedUUID = seg.UUID;
log(`Skipping ${seg.category} segment at ${currentTime.toFixed(2)}s -> ${endTime.toFixed(2)}s`);
skipToTime(endTime);
break;
}
}
}
rafSkipId = requestAnimationFrame(checkAndSkip);
}
rafSkipId = requestAnimationFrame(checkAndSkip);
}
function stopRAFSkipLoop() {
if (rafSkipId) {
cancelAnimationFrame(rafSkipId);
rafSkipId = null;
}
}
function scheduleSkips() {
// For Vinegar/iOS, use RAF-based skipping for better responsiveness
if (IS_VINEGAR || IS_MOBILE_YOUTUBE) {
if (!video?.paused && skippableSegments.length > 0) {
startRAFSkipLoop();
}
return;
}
// Desktop: use timer-based approach
if (skipScheduleTimer) {
clearTimeout(skipScheduleTimer);
skipScheduleTimer = null;
}
if (!video || video.paused || !skippableSegments.length) return;
const currentTime = video.currentTime;
const result = findNextSegment(currentTime);
if (!result) return;
const { segment: nextSegment } = result;
const [startTime, endTime] = nextSegment.segment;
if (currentTime >= startTime - SKIP_BUFFER) {
if (lastSkippedUUID !== nextSegment.UUID) {
lastSkippedUUID = nextSegment.UUID;
log(`Skipping ${nextSegment.category} segment`);
skipToTime(endTime);
currentSegmentIndex++;
}
setTimeout(scheduleSkips, 50);
return;
}
const timeUntilStart = (startTime - currentTime) / video.playbackRate;
const delayMs = Math.max(0, timeUntilStart * 1000 - 50);
skipScheduleTimer = setTimeout(() => {
if (!video || video.paused) return;
const nowTime = video.currentTime;
if (
nowTime >= startTime - SKIP_BUFFER &&
nowTime < endTime - SKIP_BUFFER
) {
if (lastSkippedUUID !== nextSegment.UUID) {
lastSkippedUUID = nextSegment.UUID;
log(`Skipping ${nextSegment.category} segment`);
skipToTime(endTime);
currentSegmentIndex++;
}
}
scheduleSkips();
}, delayMs);
}
// ==================== PREVIEW BAR ====================
function createPreviewBar() {
const container = document.createElement("ul");
container.id = "sb-lite-previewbar";
return container;
}
function createSegmentBar(segment, duration) {
const bar = document.createElement("li");
bar.className = "sb-lite-segment";
const startTime = segment.segment[0];
const endTime = Math.min(segment.segment[1], duration);
const startPercent = (startTime / duration) * 100;
const endPercent = (endTime / duration) * 100;
bar.style.left = `${startPercent}%`;
bar.style.right = `${100 - endPercent}%`;
bar.style.backgroundColor = CATEGORY_COLORS[segment.category] || "#888";
// Add title tooltip
bar.title = segment.category.replace(/_/g, " ");
return bar;
}
function getProgressBar() {
// Bilibili
if (IS_BILIBILI) {
return (
document.querySelector(".bpx-player-progress-wrap") ||
document.querySelector(".bilibili-player-video-progress") ||
document.querySelector(".squirtle-progress-wrap") ||
document.querySelector(".bpx-player-progress")
);
}
// Desktop YouTube
let progressBar = document.querySelector(".ytp-progress-bar");
// YouTube Music
if (!progressBar && IS_MUSIC_YOUTUBE) {
progressBar = document.querySelector("#progress-bar");
}
// Mobile YouTube - try multiple selectors
if (!progressBar && IS_MOBILE_YOUTUBE) {
progressBar =
document.querySelector(".progress-bar-line") ||
document.querySelector(".advancement-bar-line") ||
document.querySelector(".advancement-bar") ||
document.querySelector("ytm-player .progress-bar") ||
document.querySelector(".player-controls-content .progress-bar-line") ||
document.querySelector("[class*='progress-bar']");
}
return progressBar;
}
function clearPreviewBar() {
if (previewBarContainer) {
previewBarContainer.innerHTML = "";
}
}
function removePreviewBar() {
if (previewBarContainer) {
previewBarContainer.remove();
previewBarContainer = null;
}
}
function updatePreviewBar() {
const duration = getVideoDuration();
if (!duration || duration <= 0) return;
videoDuration = duration;
// Get or create container
if (!previewBarContainer) {
previewBarContainer = createPreviewBar();
}
// Attach to progress bar if not already attached
const progressBar = getProgressBar();
if (progressBar && !progressBar.contains(previewBarContainer)) {
// Ensure progress bar has relative positioning for absolute children
const computedStyle = window.getComputedStyle(progressBar);
if (computedStyle.position === "static") {
progressBar.style.position = "relative";
}
progressBar.appendChild(previewBarContainer);
}
if (!progressBar) {
// For Vinegar, this is expected since native controls can't be modified
if (IS_VINEGAR) {
log("Preview bar not available (Vinegar/native controls)");
} else {
log("Progress bar not found, will retry...");
}
return;
}
// Clear existing bars
clearPreviewBar();
// Filter segments for preview bar (exclude ActionType.Full)
const previewSegments = segments.filter((s) => s.actionType !== "full");
// Sort by duration (longer first) to render properly
const sortedSegments = [...previewSegments].sort(
(a, b) => b.segment[1] - b.segment[0] - (a.segment[1] - a.segment[0]),
);
// Create segment bars
for (const segment of sortedSegments) {
// Skip music_offtopic on non-music YouTube
if (segment.category === "music_offtopic" && !IS_MUSIC_YOUTUBE) {
continue;
}
const bar = createSegmentBar(segment, duration);
previewBarContainer.appendChild(bar);
}
}
// ==================== CATEGORY PILL ====================
function createCategoryPill() {
const pill = document.createElement("span");
pill.id = "sb-lite-category-pill";
return pill;
}
function attachCategoryPill() {
let pill = document.getElementById("sb-lite-category-pill");
if (!pill) {
pill = createCategoryPill();
}
let titleContainer = null;
if (IS_BILIBILI) {
// Bilibili title selectors
titleContainer =
document.querySelector(".video-title") ||
document.querySelector(".title-text") ||
document.querySelector("h1.video-title") ||
document.querySelector(".video-info-title");
} else if (IS_MUSIC_YOUTUBE) {
titleContainer = document.querySelector("ytmusic-player-bar .title");
} else if (IS_MOBILE_YOUTUBE) {
// Mobile YouTube title selectors
titleContainer =
document.querySelector(
".slim-video-metadata-header .slim-owner-icon-and-title",
) ||
document.querySelector("ytm-slim-owner-renderer") ||
document.querySelector(".slim-video-information-title") ||
document.querySelector(".slim-video-metadata-title") ||
document.querySelector("[class*='video-title']") ||
document.querySelector("h2.slim-video-information-title");
} else {
// Desktop YouTube
titleContainer =
document.querySelector("#above-the-fold #title h1") ||
document.querySelector("ytd-watch-metadata #title h1") ||
document.querySelector("#info-contents h1") ||
document.querySelector("h1.ytd-video-primary-info-renderer");
}
if (titleContainer && !titleContainer.contains(pill)) {
titleContainer.style.display = "flex";
titleContainer.style.alignItems = "center";
titleContainer.style.flexWrap = "wrap";
titleContainer.appendChild(pill);
}
return pill;
}
function showCategoryPill(segment) {
const pill = attachCategoryPill();
if (!pill) return;
const label = CATEGORY_LABELS[segment.category] || segment.category;
const color = CATEGORY_COLORS[segment.category] || "#008a5c";
pill.textContent = label;
pill.style.backgroundColor = color;
pill.style.display = "inline-flex";
}
function hideCategoryPill() {
const pill = document.getElementById("sb-lite-category-pill");
if (pill) {
pill.style.display = "none";
}
}
function updateCategoryPill() {
const fullVideoSegment = segments.find((s) => s.actionType === "full");
if (fullVideoSegment) {
showCategoryPill(fullVideoSegment);
} else {
hideCategoryPill();
}
}
// ==================== VIDEO LISTENERS ====================
function setupVideoListeners() {
if (!video) return;
// Re-check Vinegar detection now that we have a video
updateVinegarDetection();
const videoId = video.getAttribute("data-sb-lite-initialized");
const currentSrc = video.currentSrc || video.src;
// Check if this is a new video or if the source changed
if (videoId === currentVideoID && lastVideoSrc === currentSrc) return;
video.setAttribute("data-sb-lite-initialized", currentVideoID);
lastVideoSrc = currentSrc;
log("Setting up video listeners" + (IS_VINEGAR ? " (Vinegar mode)" : ""));
// Remove any existing listeners by cloning (for Vinegar video replacement scenario)
// We'll use named functions and track them instead
const onPlay = () => {
log("Video play event");
scheduleSkips();
};
const onPlaying = () => {
log("Video playing event");
scheduleSkips();
};
const onSeeked = () => {
log("Video seeked event");
lastSkippedUUID = null;
currentSegmentIndex = 0;
if (!video.paused) {
scheduleSkips();
}
};
const onRateChange = () => {
scheduleSkips();
};
const onPause = () => {
log("Video pause event");
if (skipScheduleTimer) {
clearTimeout(skipScheduleTimer);
skipScheduleTimer = null;
}
stopRAFSkipLoop();
};
const onDurationChange = () => {
if (segments.length > 0) {
updatePreviewBar();
}
};
const onLoadedMetadata = () => {
log("Video loadedmetadata event");
if (segments.length > 0) {
updatePreviewBar();
}
};
video.addEventListener("play", onPlay);
video.addEventListener("playing", onPlaying);
video.addEventListener("seeked", onSeeked);
video.addEventListener("ratechange", onRateChange);
video.addEventListener("pause", onPause);
video.addEventListener("durationchange", onDurationChange);
video.addEventListener("loadedmetadata", onLoadedMetadata);
// For Vinegar/iOS: also listen to timeupdate as backup
// (RAF loop is primary, but timeupdate helps when app is backgrounded)
if (IS_VINEGAR || IS_MOBILE_YOUTUBE) {
const onTimeUpdate = () => {
if (!video.paused && skippableSegments.length > 0) {
const currentTime = video.currentTime;
for (const seg of skippableSegments) {
const [startTime, endTime] = seg.segment;
if (
currentTime >= startTime &&
currentTime < endTime - SKIP_BUFFER &&
lastSkippedUUID !== seg.UUID
) {
lastSkippedUUID = seg.UUID;
log(`Skipping ${seg.category} segment (timeupdate backup)`);
skipToTime(endTime);
break;
}
}
}
};
video.addEventListener("timeupdate", onTimeUpdate);
}
// For Vinegar: Monitor for video element replacement
if (IS_VINEGAR) {
// Also start skip loop immediately if video is already playing
if (!video.paused && skippableSegments.length > 0) {
startRAFSkipLoop();
}
}
}
function findVideoElement() {
// Bilibili selectors
if (IS_BILIBILI) {
video =
document.querySelector(".bpx-player-video-area video") ||
document.querySelector(".bilibili-player video") ||
document.querySelector("video");
return video;
}
// For Vinegar (or when YouTube player is replaced), just find any video element
const anyVideo = document.querySelector("video");
if (anyVideo) {
// Check if this looks like a Vinegar setup (no YouTube player elements)
const hasYouTubePlayer = document.querySelector("#movie_player, ytm-player") !== null;
if (!hasYouTubePlayer) {
if (!IS_VINEGAR) {
IS_VINEGAR = true;
log("Vinegar/native video detected");
}
video = anyVideo;
return video;
}
}
// Desktop selectors
video =
document.querySelector("video.html5-main-video") ||
document.querySelector("video.video-stream") ||
document.querySelector("#movie_player video");
// Mobile selectors
if (!video && IS_MOBILE_YOUTUBE) {
video =
document.querySelector("ytm-player video") ||
document.querySelector(".player-container video") ||
document.querySelector(".html5-video-container video") ||
document.querySelector(".video-stream") ||
document.querySelector("video[playsinline]") ||
document.querySelector("video");
}
// Fallback
if (!video) {
video = document.querySelector("video");
}
return video;
}
// ==================== MUTATION OBSERVER FOR VIDEO ====================
function setupVideoObserver() {
if (videoObserver) {
videoObserver.disconnect();
}
videoObserver = new MutationObserver((mutations) => {
// Check if video element was added or replaced
const currentVideo = document.querySelector("video");
if (currentVideo && currentVideo !== video) {
log("Video element change detected via observer");
video = currentVideo;
// Re-check Vinegar status
updateVinegarDetection();
if (currentVideoID) {
setupVideoListeners();
if (segments.length > 0 && !video.paused) {
scheduleSkips();
}
}
} else if (!currentVideo && video) {
log("Video element removed");
video = null;
stopRAFSkipLoop();
}
});
videoObserver.observe(document.body, {
childList: true,
subtree: true,
});
}
// ==================== NAVIGATION & INITIALIZATION ====================
function resetState() {
currentVideoID = null;
segments = [];
skippableSegments = [];
lastSkippedUUID = null;
currentSegmentIndex = 0;
videoDuration = 0;
lastVideoSrc = null;
if (skipScheduleTimer) {
clearTimeout(skipScheduleTimer);
skipScheduleTimer = null;
}
stopRAFSkipLoop();
hideCategoryPill();
removePreviewBar();
}
async function loadSegmentsAndSetup() {
if (!currentVideoID) return;
try {
segments = await fetchSegments(currentVideoID);
if (segments.length > 0) {
log(`Found ${segments.length} segments for video ${currentVideoID}`);
}
computeSkippableSegments();
updateCategoryPill();
updatePreviewBar();
setupVideoListeners();
if (video && !video.paused) {
scheduleSkips();
}
// Retry preview bar attachment after a delay (for slow-loading UI)
if ((IS_MOBILE_YOUTUBE || IS_VINEGAR) && segments.length > 0) {
setTimeout(updatePreviewBar, 1000);
setTimeout(updatePreviewBar, 2000);
setTimeout(updateCategoryPill, 1000);
}
} catch (error) {
logError("Failed to load segments:", error);
}
}
function handleVideoChangeImpl() {
const newVideoID = getVideoID();
if (!newVideoID || newVideoID === currentVideoID) {
return;
}
log(`Video changed to: ${newVideoID}`);
resetState();
currentVideoID = newVideoID;
let attempts = 0;
const maxAttempts = 50;
const checkVideo = setInterval(() => {
attempts++;
// Re-check Vinegar detection on each attempt
updateVinegarDetection();
if (findVideoElement()) {
clearInterval(checkVideo);
log("Video element found after", attempts, "attempts");
loadSegmentsAndSetup();
} else if (attempts >= maxAttempts) {
clearInterval(checkVideo);
logError("Failed to find video element after max attempts");
}
}, 100);
}
function handleVideoChange() {
if (videoChangeDebounce) {
clearTimeout(videoChangeDebounce);
}
videoChangeDebounce = setTimeout(handleVideoChangeImpl, 50);
}
function setupNavigationListener() {
// Standard YouTube navigation events (may not fire on mobile)
document.addEventListener("yt-navigate-finish", () => {
log("yt-navigate-finish event");
handleVideoChange();
});
document.addEventListener("yt-navigate-start", () => {
hideCategoryPill();
removePreviewBar();
stopRAFSkipLoop();
});
// Mobile-specific events
if (IS_MOBILE_YOUTUBE) {
document.addEventListener("state-navigateend", () => {
log("state-navigateend event");
handleVideoChange();
});
document.addEventListener("yt-page-data-updated", () => {
log("yt-page-data-updated event");
handleVideoChange();
});
}
// History API interception
const originalPushState = history.pushState;
history.pushState = function (...args) {
originalPushState.apply(this, args);
log("pushState detected");
handleVideoChange();
};
const originalReplaceState = history.replaceState;
history.replaceState = function (...args) {
originalReplaceState.apply(this, args);
log("replaceState detected");
handleVideoChange();
};
window.addEventListener("popstate", () => {
log("popstate event");
handleVideoChange();
});
// URL polling fallback (essential for mobile and Vinegar)
urlPollInterval = setInterval(() => {
if (location.href !== lastUrl) {
log("URL change detected via polling:", location.href);
lastUrl = location.href;
handleVideoChange();
}
// For Vinegar: periodically check if video element was replaced
if (IS_VINEGAR && currentVideoID) {
const currentVideo = document.querySelector("video");
if (currentVideo && currentVideo !== video) {
log("Video element replacement detected via polling");
video = currentVideo;
setupVideoListeners();
if (skippableSegments.length > 0 && !video.paused) {
scheduleSkips();
}
}
}
}, 500);
}
function init() {
log("Initializing SponsorBlock Lite");
// Initial Vinegar detection (may update later when video loads)
updateVinegarDetection();
log(
"Platform:",
IS_BILIBILI ? "Bilibili" : IS_VINEGAR ? "Vinegar" : IS_MOBILE_YOUTUBE ? "Mobile" : IS_MUSIC_YOUTUBE ? "Music" : "Desktop",
);
injectStyles();
setupNavigationListener();
setupVideoObserver();
handleVideoChange();
// Multiple retry attempts for initial load
setTimeout(handleVideoChange, 500);
setTimeout(handleVideoChange, 1000);
setTimeout(handleVideoChange, 2000);
// Additional retries for mobile/Vinegar
if (IS_MOBILE_YOUTUBE || IS_VINEGAR) {
setTimeout(handleVideoChange, 3000);
setTimeout(handleVideoChange, 5000);
}
// For Vinegar: also retry after longer delays since the player loads differently
setTimeout(() => {
updateVinegarDetection();
if (IS_VINEGAR) {
log("Late Vinegar detection check");
handleVideoChange();
}
}, 4000);
}
// ==================== START ====================
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();