// ==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 // @downloadURL https://update.greasyfork.icu/scripts/560869/SponsorBlock%20Lite.user.js // @updateURL https://update.greasyfork.icu/scripts/560869/SponsorBlock%20Lite.meta.js // ==/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(); } })();