diff --git a/README.md b/README.md index baadebc..f9a3217 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@ Flow is a static web application. +The built-in music player skips sponsored segments in YouTube videos using the +public [SponsorBlock](https://sponsor.ajay.app/) API. Segments flagged as +`sponsor`, `selfpromo`, `intro`, `outro`, and other common categories are +automatically skipped at playback time. + +The application also blocks many network ad requests by intercepting +`fetch` and `XMLHttpRequest` calls to common advertising domains such as +`doubleclick.net` and `googlesyndication.com`. + ## Production deployment The site is served from the `gh-pages` branch. Any push to `main` automatically deploys the latest files to the root of the Pages site. diff --git a/js/focus.js b/js/focus.js index 83122b6..3ab38c3 100644 --- a/js/focus.js +++ b/js/focus.js @@ -7,6 +7,8 @@ import storageService from './storage.js'; import { TimerCore } from './timerCore.js'; import { initSounds } from './sound.js'; import { initAdBlocker } from './adBlocker.js'; // Import our ad blocker +import { initNetworkAdBlocker } from './networkAdBlocker.js'; +import { initSponsorBlocker } from './sponsorBlocker.js'; // DOM elements let timerEl, circularProgressEl; @@ -230,6 +232,10 @@ async function continueMusicPlayback() { // Initialize ad blocker for the focus mode YouTube player initAdBlocker(ytPlayer); + // Block network ad requests + initNetworkAdBlocker(); + // Initialize SponsorBlocker to skip sponsors + initSponsorBlocker(ytPlayer, videoID); // Add a play button to the YouTube container const playButtonContainer = document.createElement('div'); diff --git a/js/music.js b/js/music.js index cc3477e..781727d 100644 --- a/js/music.js +++ b/js/music.js @@ -10,7 +10,9 @@ import { musicLabels } from './constants.js'; import storageService from './storage.js'; -import { initAdBlocker } from './adBlocker.js'; // Import our new ad blocker +import { initAdBlocker } from './adBlocker.js'; // Import our ad blocker +import { initNetworkAdBlocker } from './networkAdBlocker.js'; +import { initSponsorBlocker, removeSponsorBlocker } from './sponsorBlocker.js'; // SponsorBlock integration // Music elements let ytPlayer, customVidInput; @@ -70,9 +72,13 @@ export async function initMusic() { // Initialize YouTube player with the remembered video ytPlayer.src = `https://www.youtube.com/embed/${currentVideoID}?autoplay=0&loop=1&playlist=${currentVideoID}&rel=0&controls=1&iv_load_policy=3&modestbranding=1&enablejsapi=1&origin=${window.location.origin}`; - + // Initialize the ad blocker for the YouTube player initAdBlocker(ytPlayer); + // Block network ad requests + initNetworkAdBlocker(); + // Initialize SponsorBlocker to skip sponsored segments + initSponsorBlocker(ytPlayer, currentVideoID); // Update button labels updateButtonLabels(); @@ -87,9 +93,14 @@ async function changeVideo(id) { // Updated YouTube embed URL with ad-blocking parameters ytPlayer.src = `https://www.youtube.com/embed/${id}?autoplay=1&loop=1&playlist=${id}&rel=0&controls=1&iv_load_policy=3&modestbranding=1&enablejsapi=1&origin=${window.location.origin}`; - - // Re-initialize the ad blocker for the new video - setTimeout(() => initAdBlocker(ytPlayer), 500); + + // Re-initialize the ad and sponsor blockers for the new video + setTimeout(() => { + initAdBlocker(ytPlayer); + initNetworkAdBlocker(); + removeSponsorBlocker(ytPlayer); + initSponsorBlocker(ytPlayer, id); + }, 500); await saveLastVideoIDToStorage(id); setCurrentVideo(id); diff --git a/js/networkAdBlocker.js b/js/networkAdBlocker.js new file mode 100644 index 0000000..7d72dc3 --- /dev/null +++ b/js/networkAdBlocker.js @@ -0,0 +1,39 @@ +// Network-level ad blocker for YouTube embeds +// Blocks known advertising domains by intercepting fetch and XHR requests. + +const BLOCKED_HOSTS = [ + 'doubleclick.net', + 'googleadservices.com', + 'googlesyndication.com', + 'youtube.com/api/stats/ads' +]; + +function shouldBlock(url) { + try { + const parsed = new URL(url, window.location.href); + return BLOCKED_HOSTS.some(host => parsed.hostname.includes(host)); + } catch { + return false; + } +} + +export function initNetworkAdBlocker() { + const originalFetch = window.fetch; + window.fetch = function(url, options) { + if (shouldBlock(url)) { + console.warn('[NetworkAdBlocker] Blocked request to', url); + return Promise.resolve(new Response('', { status: 204 })); + } + return originalFetch.call(this, url, options); + }; + + const origOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(method, url) { + if (shouldBlock(url)) { + console.warn('[NetworkAdBlocker] Blocked XHR to', url); + this.abort(); + return; + } + return origOpen.apply(this, arguments); + }; +} diff --git a/js/sponsorBlocker.js b/js/sponsorBlocker.js new file mode 100644 index 0000000..67ce5e5 --- /dev/null +++ b/js/sponsorBlocker.js @@ -0,0 +1,129 @@ +// SponsorBlocker integration for YouTube embeds +// Fetch sponsor segment data using the SponsorBlock public API and +// automatically seek past those sections while the video plays. + +const activePlayers = new Map(); +const API_URL = 'https://sponsor.ajay.app/api/skipSegments'; +const DEFAULT_CATEGORIES = [ + 'sponsor', + 'selfpromo', + 'interaction', + 'intro', + 'outro', + 'preview', + 'music_offtopic' +]; + +/** + * Initialize SponsorBlocker for a YouTube iframe element. + * Uses the YouTube Iframe API via postMessage so we don't need to inject + * code into the cross‑origin iframe. + * + * @param {HTMLIFrameElement} ytPlayerElement - The YouTube iframe element. + * @param {string} videoID - The YouTube video ID. + */ +export async function initSponsorBlocker(ytPlayerElement, videoID) { + if (!ytPlayerElement || !videoID) return; + + // Clean up any existing blocker on this element + removeSponsorBlocker(ytPlayerElement); + + const segments = await fetchSponsorSegments(videoID); + if (!segments.length) return; + + // Unique id for requests so we can match responses from the iframe + const requestId = `sb_${Date.now()}`; + + const onMessage = (event) => { + if (!event.origin.includes('youtube.com')) return; + if (typeof event.data !== 'string') return; + + let data; + try { + data = JSON.parse(event.data); + } catch { + return; + } + + if (data.event === 'infoDelivery' && data.id === requestId) { + const time = Number(data.info); + if (Number.isFinite(time)) { + checkSegments(time); + } + } + }; + + function pollCurrentTime() { + ytPlayerElement.contentWindow.postMessage( + JSON.stringify({ event: 'command', func: 'getCurrentTime', id: requestId }), + '*' + ); + } + + function checkSegments(currentTime) { + for (const [start, end] of segments) { + if (currentTime >= start && currentTime < end) { + ytPlayerElement.contentWindow.postMessage( + JSON.stringify({ + event: 'command', + func: 'seekTo', + args: [end, true], + }), + '*' + ); + break; + } + } + } + + const sendListening = () => { + ytPlayerElement.contentWindow.postMessage( + JSON.stringify({ event: 'listening', id: requestId }), + '*' + ); + }; + + const loadListener = () => sendListening(); + ytPlayerElement.addEventListener('load', loadListener); + + // Attempt to send the initial listening command immediately + sendListening(); + + const intervalId = setInterval(pollCurrentTime, 1000); + window.addEventListener('message', onMessage); + activePlayers.set(ytPlayerElement, { intervalId, onMessage, loadListener }); +} + +/** + * Clean up SponsorBlocker listeners for a specific iframe. + * @param {HTMLIFrameElement} ytPlayerElement - The YouTube iframe element. + */ +export function removeSponsorBlocker(ytPlayerElement) { + const entry = activePlayers.get(ytPlayerElement); + if (!entry) return; + clearInterval(entry.intervalId); + window.removeEventListener('message', entry.onMessage); + if (entry.loadListener) { + ytPlayerElement.removeEventListener('load', entry.loadListener); + } + activePlayers.delete(ytPlayerElement); +} + +async function fetchSponsorSegments(videoID) { + try { + const categories = encodeURIComponent( + JSON.stringify(DEFAULT_CATEGORIES) + ); + const url = `${API_URL}?videoID=${encodeURIComponent( + videoID + )}&categories=${categories}`; + + const resp = await fetch(url); + const data = await resp.json(); + return (data || []).map((s) => s.segment.map(Number)); + } catch (err) { + console.error('[SponsorBlocker] API error', err); + return []; + } +} +