Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions js/focus.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
21 changes: 16 additions & 5 deletions js/music.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down
39 changes: 39 additions & 0 deletions js/networkAdBlocker.js
Original file line number Diff line number Diff line change
@@ -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);
};
}
129 changes: 129 additions & 0 deletions js/sponsorBlocker.js
Original file line number Diff line number Diff line change
@@ -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 [];
}
}