Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:

- name: Check JS syntax
run: |
for f in background.js content.js popup.js dashboard.js; do
for f in background.js content.js popup.js dashboard.js youtube.js youtube-inject.js; do
node --check "$f"
done

Expand All @@ -36,6 +36,7 @@ jobs:
(cs.js || []).forEach(check);
(cs.css || []).forEach(check);
}
check('youtube-inject.js'); // registered at runtime by background.js
for (const r of m.declarative_net_request?.rule_resources || []) check(r.path);
for (const size of Object.keys(m.icons || {})) check(m.icons[size]);
if (missing.length) { console.error('Missing files:', missing); process.exit(1); }
Expand Down
41 changes: 40 additions & 1 deletion background.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,41 @@ async function rebuildAllowRules(whitelist) {
return replaceDynamicRulesInRange(rules, DYNAMIC_ALLOW_START, DYNAMIC_ALLOW_START + 9999);
}

// ─────────────────────────────────────────────
// YouTube player data pruning (youtube-inject.js)
// Runs in the page's world, where it can't read storage: register it only while
// protection is active, excluding allow-listed domains, so pause/off/allow list
// apply from the very first script of the page.
// ─────────────────────────────────────────────
const YT_INJECT_ID = 'goablockad-youtube-inject';
let ytSyncQueue = Promise.resolve();

// Serialized: concurrent unregister/register pairs would collide on the script id.
function syncYouTubeInjection() {
ytSyncQueue = ytSyncQueue.then(doSyncYouTubeInjection);
return ytSyncQueue;
}

async function doSyncYouTubeInjection() {
const { whitelist = [] } = await chrome.storage.local.get('whitelist');
const active = await isProtectionActive();
try {
await chrome.scripting.unregisterContentScripts({ ids: [YT_INJECT_ID] }).catch(() => {});
if (!active) return;
await chrome.scripting.registerContentScripts([{
id: YT_INJECT_ID,
js: ['youtube-inject.js'],
matches: ['*://*.youtube.com/*', '*://*.youtube-nocookie.com/*'],
excludeMatches: whitelist.flatMap(d => [`*://${d}/*`, `*://*.${d}/*`]),
runAt: 'document_start',
world: 'MAIN',
allFrames: true
}]);
} catch (err) {
console.error('GoaBlockAD: YouTube script registration failed', err);
}
}

// ─────────────────────────────────────────────
// Pause mode
// ─────────────────────────────────────────────
Expand All @@ -135,6 +170,7 @@ async function applyProtectionState() {
}
// Custom rules are dynamic and ignore the ruleset toggle: add/remove them too.
await rebuildCustomRules();
await syncYouTubeInjection();
updateBadgeStyle(shouldEnable);
}

Expand Down Expand Up @@ -194,7 +230,10 @@ chrome.runtime.onStartup.addListener(async () => {
// ─────────────────────────────────────────────
chrome.storage.onChanged.addListener(async (changes, namespace) => {
if (namespace !== 'local') return;
if (changes.whitelist) await rebuildAllowRules(changes.whitelist.newValue || []);
if (changes.whitelist) {
await rebuildAllowRules(changes.whitelist.newValue || []);
await syncYouTubeInjection();
}
if (changes.enabled || changes.pausedUntil) {
await applyProtectionState();
await refreshBadgeText();
Expand Down
14 changes: 13 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"declarativeNetRequest",
"storage",
"alarms",
"activeTab"
"activeTab",
"scripting"
],
"host_permissions": [
"<all_urls>"
Expand Down Expand Up @@ -50,6 +51,17 @@
"content.js"
],
"run_at": "document_idle"
},
{
"matches": [
"*://*.youtube.com/*",
"*://*.youtube-nocookie.com/*"
],
"js": [
"youtube.js"
],
"run_at": "document_start",
"all_frames": true
}
]
}
27 changes: 27 additions & 0 deletions rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -295,5 +295,32 @@
"urlFilter": "||smartadserver.com^",
"resourceTypes": ["script", "image", "xmlhttprequest", "sub_frame"]
}
},
{
"id": 34,
"priority": 1,
"action": { "type": "block" },
"condition": {
"urlFilter": "||youtube.com/api/stats/ads",
"resourceTypes": ["script", "image", "xmlhttprequest", "sub_frame", "ping", "other"]
}
},
{
"id": 35,
"priority": 1,
"action": { "type": "block" },
"condition": {
"urlFilter": "||youtube.com/pagead/",
"resourceTypes": ["script", "image", "xmlhttprequest", "sub_frame", "ping", "other"]
}
},
{
"id": 36,
"priority": 1,
"action": { "type": "block" },
"condition": {
"urlFilter": "||youtube.com/ptracking",
"resourceTypes": ["script", "image", "xmlhttprequest", "sub_frame", "ping", "other"]
}
}
]
101 changes: 101 additions & 0 deletions youtube-inject.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// GoaBlockAD - YouTube player data pruning
// Runs in the page (MAIN world) at document_start, so it sees the player data
// before the player does: ad fields are removed from the initial player response
// embedded in the page and from the youtubei API responses fetched on SPA
// navigation. The player then never schedules an ad.
// Registered dynamically by background.js only while protection is active and
// YouTube isn't allow-listed — this script has no access to extension storage.
// Based on the approach proposed by @PoPoGH in #10.

(() => {
if (window.__goablockadYt) return;
window.__goablockadYt = true;

const AD_KEYS = new Set(['playerAds', 'adPlacements', 'adSlots', 'adBreakHeartbeatParams']);
const API_PATHS = ['/youtubei/v1/player', '/youtubei/v1/next', '/youtubei/v1/reel/reel_watch_sequence'];

function prune(obj) {
if (!obj || typeof obj !== 'object') return obj;
for (const key of Object.keys(obj)) {
if (AD_KEYS.has(key)) delete obj[key];
else prune(obj[key]);
}
return obj;
}

function pruneText(text) {
try {
return JSON.stringify(prune(JSON.parse(text)));
} catch (_) {
return text;
}
}

function isApiUrl(url) {
try {
const { pathname } = new URL(String(url), location.href);
return API_PATHS.some(p => pathname.startsWith(p));
} catch (_) {
return false;
}
}

// ── Data embedded in the page (first load of /watch, /shorts) ──
// YouTube assigns `var ytInitialPlayerResponse = {...}` in an inline script;
// an accessor on window catches that assignment.
let initialPlayerResponse;
Object.defineProperty(window, 'ytInitialPlayerResponse', {
configurable: true,
get: () => initialPlayerResponse,
set: (value) => { initialPlayerResponse = prune(value); }
});

// ── fetch ──
const origFetch = window.fetch;
window.fetch = function (input, init) {
const promise = origFetch.apply(this, arguments);
const url = input instanceof Request ? input.url : input;
if (!isApiUrl(url)) return promise;
return promise.then(response => {
if (!response.ok) return response;
return response.clone().text().then(text => {
const pruned = new Response(pruneText(text), {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
Object.defineProperty(pruned, 'url', { value: response.url });
return pruned;
}, () => response);
});
};

// ── XMLHttpRequest ──
const xhrProto = XMLHttpRequest.prototype;
const origOpen = xhrProto.open;
const responseText = Object.getOwnPropertyDescriptor(xhrProto, 'responseText').get;
const response = Object.getOwnPropertyDescriptor(xhrProto, 'response').get;

xhrProto.open = function (method, url) {
if (isApiUrl(url)) {
let raw, pruned;
const cached = (value) => {
if (value !== raw) { raw = value; pruned = pruneText(value); }
return pruned;
};
Object.defineProperty(this, 'responseText', {
configurable: true,
get() { return cached(responseText.call(this)); }
});
Object.defineProperty(this, 'response', {
configurable: true,
get() {
const value = response.call(this);
if (typeof value === 'string') return cached(value);
return this.responseType === 'json' ? prune(value) : value;
}
});
}
return origOpen.apply(this, arguments);
};
})();
151 changes: 151 additions & 0 deletions youtube.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// GoaBlockAD - YouTube player fallback
// youtube-inject.js removes ads from the player data; this script catches what
// slips through. YouTube serves video ads from googlevideo.com, like the videos
// themselves, so they can't be blocked at the network level: instead the ad is
// hidden, muted and fast-forwarded, and the skip button clicked. Static ad slots
// (masthead, feed, banners) are hidden with CSS.
// Merges #11 and the player handling proposed by @PoPoGH in #10.

const YT_STYLE_ID = 'goablockad-youtube-style';
const SKIP_SELECTORS = [
'.ytp-ad-skip-button',
'.ytp-ad-skip-button-modern',
'.ytp-skip-ad-button',
'.ytp-ad-skip-button-container button',
'button[class*="ytp-ad-skip"]',
'button[class*="ytp-skip-ad"]'
];
// Hides the ad while it's still on screen, before the skip kicks in.
const PLAYER_AD_CSS = `
.html5-video-player.ad-showing video,
.html5-video-player.ad-interrupting video {
opacity: 0 !important;
}
.ytp-ad-module,
.ytp-ad-player-overlay,
.ytp-ad-player-overlay-layout,
.ytp-ad-image-overlay,
.ytp-ad-overlay-container,
.ytp-ad-text-overlay,
.ytp-ad-action-interstitial,
.ytp-flyout-cta,
.ytp-ad-survey {
display: none !important;
}
`;
// Cosmetic part, follows the "cosmetic" setting like content.js.
const AD_SLOTS_CSS = `
#masthead-ad,
#player-ads,
ytd-ad-slot-renderer,
ytd-rich-item-renderer:has(ytd-ad-slot-renderer),
ytd-in-feed-ad-layout-renderer,
ytd-display-ad-renderer,
ytd-promoted-video-renderer,
ytd-promoted-sparkles-web-renderer,
ytd-promoted-sparkles-text-search-renderer,
ytd-banner-promo-renderer,
ytd-statement-banner-renderer,
ytd-companion-slot-renderer,
ytd-action-companion-ad-renderer,
ytd-player-legacy-desktop-watch-ads-renderer,
ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-ads"],
ytd-merch-shelf-renderer,
ytm-companion-slot,
ytmusic-ad-slot-renderer,
ytmusic-statement-banner-renderer {
display: none !important;
}
`;

let pollTimer = null;
// Per <video> element: the ad source already fast-forwarded, the grace period
// after a source change, and whether we muted it.
const videoState = new WeakMap();

function isWhitelisted(whitelist) {
const host = location.hostname.replace(/^www\./, '');
return (whitelist || []).some(d => host === d || host.endsWith('.' + d));
}

function setStyle(css) {
let style = document.getElementById(YT_STYLE_ID);
if (!css) return style?.remove();
if (!style) {
style = document.createElement('style');
style.id = YT_STYLE_ID;
(document.head || document.documentElement).appendChild(style);
}
style.textContent = css;
}

function stateOf(video) {
let s = videoState.get(video);
if (!s) {
s = { skippedSrc: null, graceUntil: 0, mutedByUs: false };
videoState.set(video, s);
// On a source change the player may still carry `ad-showing` for a
// moment: wait before fast-forwarding, or the real video gets sent to its end.
video.addEventListener('loadstart', () => {
s.skippedSrc = null;
s.graceUntil = Date.now() + 150;
});
}
return s;
}

function handlePlayer() {
const player = document.querySelector('.html5-video-player');
const video = player?.querySelector('video');
if (!video) return;
const s = stateOf(video);
const adPlaying = player.classList.contains('ad-showing') || player.classList.contains('ad-interrupting');

if (!adPlaying) {
if (s.mutedByUs) {
video.muted = false;
s.mutedByUs = false;
}
return;
}

if (!video.muted) {
video.muted = true;
s.mutedByUs = true;
}
if (Date.now() >= s.graceUntil && s.skippedSrc !== video.src &&
isFinite(video.duration) && video.duration > 0) {
s.skippedSrc = video.src;
video.currentTime = video.duration;
}
// Covers ads that can't be seeked
for (const sel of SKIP_SELECTORS) {
const btn = player.querySelector(sel);
if (btn) { btn.click(); break; }
}
document.querySelector('.ytp-ad-overlay-close-button')?.click();
}

function syncState() {
chrome.storage.local.get(['enabled', 'cosmetic', 'whitelist', 'pausedUntil'], (result) => {
const paused = result.pausedUntil && Date.now() < result.pausedUntil;
const active = result.enabled !== false && !paused && !isWhitelisted(result.whitelist);
if (!active) {
setStyle('');
clearInterval(pollTimer);
pollTimer = null;
return;
}
setStyle(PLAYER_AD_CSS + (result.cosmetic !== false ? AD_SLOTS_CSS : ''));
// YouTube is a SPA and rebuilds the player across navigations; a light poll
// is more reliable than an observer scoped to a transient node.
pollTimer ??= setInterval(handlePlayer, 250);
});
}

syncState();

chrome.storage.onChanged.addListener((changes, namespace) => {
if (namespace !== 'local') return;
if (changes.enabled || changes.cosmetic || changes.whitelist || changes.pausedUntil) syncState();
});