diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3476c73..21216d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 1.3.0 (2026-11-11)
+
+- Twitch: GTA streams are now marked red instead of hidden, and opening one shows a warning before the stream plays ("Go back" or "Watch anyway"). After "Watch anyway" the stream keeps a red frame.
+- New setting "Twitch": mark red and warn (default) or remove GTA streams completely (previous behaviour).
+
## 1.2.0 (2026-11-11)
- The extension's own GitHub repository is never blocked (hardcoded): pages, issues, raw files, zip and release downloads.
diff --git a/README.md b/README.md
index 6c0a8b0..8084fbd 100644
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@ Everything runs locally in your browser. No tracking, no analytics, no remote co
| **Before they load** | Any GTA search (`gta 6`, `grand theft auto v`, `vice city`, `#gtaonline`...) on Google, Bing, DuckDuckGo, YouTube, Instagram, X etc., GTA hashtag pages, `rockstargames.com/VI` and other GTA pages on Rockstar's site, GTA fan sites (GTAForums, GTABase, the GTA wiki...) and embedded GTA iframes are stopped at the network level. |
| **Search results** | Google, Bing, DuckDuckGo, Brave, Ecosia, Yahoo result cards. |
| **Videos** | YouTube home, search, sidebar, Shorts shelves, comments and community posts; TikTok cards. |
-| **Twitch** | Streams in any Grand Theft Auto category (GTA VI, V, IV, San Andreas, Vice City...) are hidden from the sidebar, front page and directory, and a channel page streaming GTA is blocked. If a streamer switches to GTA mid-stream, the stream is blocked right away. |
+| **Twitch** | Streams in any Grand Theft Auto category (GTA VI, V, IV, San Andreas, Vice City...) are marked red in the sidebar, front page and directory, and opening one shows a warning before the stream plays (go back or watch anyway). Also when a streamer switches to GTA mid-stream. In the settings you can instead remove GTA streams completely. |
| **Swipe feeds** | In the YouTube Shorts player, Instagram Reels, TikTok and Facebook Reels, a GTA video is covered with a block screen and muted, and you can keep scrolling to the next one. |
| **Social posts** | Reddit, X/Twitter, Bluesky, Mastodon, Facebook and Instagram posts, captions, hashtags, grid tiles and comments. |
| **Everything else** | Any element on any site whose text, link, image `alt`, `title` or `aria-label` matches a keyword, including content loaded later by infinite scroll. |
@@ -80,6 +80,7 @@ Default keywords: `GTA`, `GTA 6`, `GTA VI`, `GTA 5`, `GTA V`, `GTA Online`, `Gra
- "Block on this site" switch to allow a site
- Badge counter showing how many items were hidden on the current tab
- Editable keyword list and allowlist in the settings page
+- Twitch setting: mark GTA streams red and warn (default), or remove them completely
- This project's own GitHub repository (`github.com/Backware/gta6-blocker`, raw files, zip and release downloads) is never blocked, so you can always read the docs, report issues and update
- No tracking, no network calls, no remote code. Settings stay in your browser.
diff --git a/docs/screenshots/twitch-red.png b/docs/screenshots/twitch-red.png
new file mode 100644
index 0000000..600bfbf
Binary files /dev/null and b/docs/screenshots/twitch-red.png differ
diff --git a/docs/screenshots/twitch-warning.png b/docs/screenshots/twitch-warning.png
new file mode 100644
index 0000000..df3c2ee
Binary files /dev/null and b/docs/screenshots/twitch-warning.png differ
diff --git a/package.json b/package.json
index a925cbf..86cf353 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "gta6-blocker",
- "version": "1.2.0",
+ "version": "1.3.0",
"private": true,
"description": "Browser extension that blocks GTA 6 / Grand Theft Auto VI content in Chrome and Safari.",
"license": "MIT",
diff --git a/src/content.css b/src/content.css
index ca3d263..815dfc0 100644
--- a/src/content.css
+++ b/src/content.css
@@ -33,3 +33,37 @@ html[data-gta6-page-blocked] body {
font: 600 16px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
visibility: visible !important;
}
+
+/* Twitch, "mark red and warn" mode: GTA streams stay visible but are
+ clearly marked, and opening one shows a warning first. */
+[data-gta6-flagged] {
+ position: relative !important;
+ outline: 3px solid #e5484d !important;
+ outline-offset: -3px;
+ background-color: rgba(229, 72, 77, 0.16) !important;
+ border-radius: 6px;
+}
+[data-gta6-flagged]::before {
+ content: "GTA";
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ z-index: 10;
+ padding: 2px 6px;
+ border-radius: 4px;
+ background: #e5484d;
+ color: #fff;
+ font: 700 11px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ letter-spacing: .04em;
+ pointer-events: none;
+}
+
+/* A GTA stream the user chose to watch anyway: red frame around the page. */
+html[data-gta6-gta-stream]::after {
+ content: "";
+ position: fixed;
+ inset: 0;
+ z-index: 2147483645;
+ border: 5px solid #e5484d;
+ pointer-events: none;
+}
diff --git a/src/content.js b/src/content.js
index a74cf7b..73b6e90 100644
--- a/src/content.js
+++ b/src/content.js
@@ -63,7 +63,7 @@
'div[data-e2e="recommend-list-item-container"]', 'article[data-e2e="recommend-list-item-container"]',
'div[data-e2e="feed-video"]', 'section[data-e2e="feed-video"]'
].join(',');
- const BLOCKED_SEL = '[data-gta6-blocked],[data-gta6-covered]';
+ const BLOCKED_SEL = '[data-gta6-blocked],[data-gta6-covered],[data-gta6-flagged]';
function isSnapFeed() {
const h = location.hostname;
@@ -122,6 +122,11 @@
// True on the extension's own GitHub repo, which is never blocked (see
// isProjectUrl in shared.js). Re-checked on every SPA navigation.
let exempt = false;
+ // Twitch: 'warn' marks GTA streams red and warns before one is opened,
+ // 'hide' removes them completely (settings page).
+ let twitchMode = 'warn';
+ let warnHost = null;
+ let warnTimer = null;
let blockedCount = 0;
let pageBlocked = false;
let overlayHost = null;
@@ -228,6 +233,14 @@
function hide(el) {
if (!el || el.nodeType !== 1 || el.closest(BLOCKED_SEL) || isEditable(el)) return;
+ if (isTwitch() && twitchMode === 'warn') {
+ const target = containerFor(el);
+ if (!target || target.contains(warnHost)) return;
+ target.setAttribute('data-gta6-flagged', '');
+ blockedCount++;
+ reportCount();
+ return;
+ }
if (isSnapFeed()) {
const player = playerFor(el);
if (player) {
@@ -313,7 +326,7 @@
// Open shadow roots (Reddit, some YouTube parts).
if (root.querySelectorAll) {
root.querySelectorAll('*').forEach((el) => {
- if (el.shadowRoot && !el.shadowRoot.__gta6Observed) {
+ if (el.shadowRoot && el !== warnHost && !el.shadowRoot.__gta6Observed) {
el.shadowRoot.__gta6Observed = true;
scanText(el.shadowRoot);
scanAttributes(el.shadowRoot);
@@ -329,7 +342,7 @@
if (urlMatches(location.href)) return true;
// Swipe feeds change the title per video; items are covered one by one instead.
if (isSnapFeed()) return false;
- if (twitchStreamIsGta()) return true;
+ if (twitchMode === 'hide' && twitchStreamIsGta()) return true;
if (matches(document.title)) return true;
const og = document.querySelector('meta[property="og:title"], meta[name="twitter:title"]');
if (og && matches(og.getAttribute('content'))) return true;
@@ -408,12 +421,147 @@
reportCount();
}
+ // ---------- Twitch warning (twitchMode 'warn') ----------
+
+ const ALLOWED_KEY = 'gta6-twitch-allowed';
+
+ /** "shroud" for twitch.tv/shroud, "videos/123" for a VOD. */
+ function twitchChannelKey(pathname) {
+ const parts = String(pathname || '').toLowerCase().split('/').filter(Boolean);
+ return parts[0] === 'videos' ? parts.slice(0, 2).join('/') : (parts[0] || '');
+ }
+
+ function allowedChannels() {
+ try { return JSON.parse(sessionStorage.getItem(ALLOWED_KEY) || '[]'); } catch (e) { return []; }
+ }
+
+ function allowChannel(key) {
+ try {
+ const list = allowedChannels();
+ if (!list.includes(key)) sessionStorage.setItem(ALLOWED_KEY, JSON.stringify(list.concat(key)));
+ } catch (e) { /* storage blocked: warn again next time */ }
+ }
+
+ function isChannelAllowed(key) {
+ return allowedChannels().includes(key);
+ }
+
+ /** Is this link a Twitch channel or video page (not directory, search, ...)? */
+ function twitchStreamLink(a) {
+ let u;
+ try { u = new URL(a.getAttribute('href'), location.href); } catch (e) { return null; }
+ if (!/(^|\.)twitch\.tv$/.test(u.hostname)) return null;
+ if (/^\/(directory|search|settings|downloads|subscriptions|drops|inventory|wallet|prime|turbo|jobs|p)?(\/|$)/i.test(u.pathname)) return null;
+ return u;
+ }
+
+ /**
+ * Full-screen warning in front of a GTA stream. The player is paused and
+ * muted while it is shown. "Watch anyway" remembers the channel for this
+ * tab, then runs onContinue (or simply reveals the stream).
+ */
+ function showTwitchWarning(key, onContinue) {
+ if (warnHost && warnHost.isConnected) return;
+ warnHost = document.createElement('gta6-twitch-warning');
+ const shadow = warnHost.attachShadow({ mode: 'open' });
+ const iconUrl = ext.runtime.getURL('icons/icon-128.png');
+ shadow.innerHTML = `
+
+
+
+
+
Warning: GTA stream
+
This stream is playing Grand Theft Auto and may contain GTA 6 spoilers.
+
+
+
+
+
+
`;
+ shadow.getElementById('gta6-back').addEventListener('click', () => {
+ if (onContinue) { hideTwitchWarning(); return; } // warned before navigating: just stay
+ if (history.length > 1) history.back();
+ else location.href = 'https://www.twitch.tv/';
+ });
+ shadow.getElementById('gta6-continue').addEventListener('click', () => {
+ allowChannel(key);
+ hideTwitchWarning();
+ if (onContinue) onContinue();
+ });
+ (document.body || document.documentElement).appendChild(warnHost);
+ if (!onContinue) {
+ // In front of the stream: keep the player paused until the user decides.
+ pauseAllMedia();
+ warnTimer = setInterval(() => {
+ pauseAllMedia();
+ if (warnHost && !warnHost.isConnected) (document.body || document.documentElement).appendChild(warnHost);
+ }, 400);
+ }
+ }
+
+ function hideTwitchWarning() {
+ clearInterval(warnTimer);
+ warnTimer = null;
+ if (warnHost) warnHost.remove();
+ warnHost = null;
+ }
+
+ /** Channel page in warn mode: red frame, plus the warning until the user continues. */
+ function updateTwitchStream() {
+ const html = document.documentElement;
+ const gta = enabled && !exempt && twitchMode === 'warn' && twitchStreamIsGta();
+ if (!gta) {
+ html.removeAttribute('data-gta6-gta-stream');
+ if (warnHost && !warnHost.__gta6Link) hideTwitchWarning();
+ return;
+ }
+ html.setAttribute('data-gta6-gta-stream', '');
+ const key = twitchChannelKey(location.pathname);
+ if (!isChannelAllowed(key)) showTwitchWarning(key, null);
+ }
+
+ // Warn before a flagged (red) stream is even opened.
+ document.addEventListener('click', (e) => {
+ if (!enabled || exempt || twitchMode !== 'warn' || !isTwitch()) return;
+ if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; // new tab: warned there
+ const t = e.target;
+ const a = t && t.closest && t.closest('a[href]');
+ if (!a || !a.closest('[data-gta6-flagged]')) return;
+ const u = twitchStreamLink(a);
+ if (!u) return;
+ const key = twitchChannelKey(u.pathname);
+ if (isChannelAllowed(key)) return;
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ hideTwitchWarning();
+ showTwitchWarning(key, () => location.assign(u.href));
+ warnHost.__gta6Link = true;
+ }, true);
+
function checkPage() {
if (!enabled) return;
if (exempt) {
if (pageBlocked) unblockPage();
+ updateTwitchStream();
return;
}
+ if (isTwitch()) updateTwitchStream();
if (pageShouldBlock()) blockPage();
else if (pageBlocked) {
unblockPage();
@@ -438,6 +586,9 @@
function clearMarks() {
document.querySelectorAll('[data-gta6-blocked]').forEach((el) => el.removeAttribute('data-gta6-blocked'));
+ document.querySelectorAll('[data-gta6-flagged]').forEach((el) => el.removeAttribute('data-gta6-flagged'));
+ document.documentElement.removeAttribute('data-gta6-gta-stream');
+ hideTwitchWarning();
document.querySelectorAll('[data-gta6-covered]').forEach((el) => el.removeAttribute('data-gta6-covered'));
}
@@ -483,7 +634,7 @@
for (const m of mutations) {
if (m.type === 'childList') {
m.addedNodes.forEach((n) => {
- if (n.nodeType === 1 && n !== overlayHost) schedule(n);
+ if (n.nodeType === 1 && n !== overlayHost && n !== warnHost) schedule(n);
else if (n.nodeType === 3 && n.parentElement) schedule(n.parentElement);
});
if (m.target.nodeName === 'TITLE' || m.target.nodeName === 'HEAD') schedule(document.documentElement);
@@ -520,6 +671,7 @@
enabled = !!s.enabled && !allowed;
exempt = isProjectUrl(location.href);
matcher = buildMatcher(s.keywords);
+ twitchMode = s.twitchMode === 'hide' ? 'hide' : 'warn';
if (!enabled) {
stopObserver();
@@ -541,7 +693,7 @@
ext.storage.onChanged.addListener((changes, area) => {
if (area !== 'local') return;
- if (changes.enabled || changes.keywords || changes.allowlist) apply();
+ if (changes.enabled || changes.keywords || changes.allowlist || changes.twitchMode) apply();
});
ext.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
diff --git a/src/manifest.json b/src/manifest.json
index b7c8945..de21cad 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "GTA 6 Blocker",
"short_name": "GTA6 Blocker",
- "version": "1.2.0",
+ "version": "1.3.0",
"description": "Blocks pages, searches, videos, Shorts, Reels and posts about GTA 6 and Grand Theft Auto. Spoiler- and hype-free browsing.",
"icons": {
"16": "icons/icon-16.png",
diff --git a/src/options/options.css b/src/options/options.css
index b6446b0..d466a90 100644
--- a/src/options/options.css
+++ b/src/options/options.css
@@ -9,3 +9,5 @@ small, .hint { color: var(--muted); }
.hint { margin: 0 0 10px; font-size: 13px; }
.actions { display: flex; align-items: center; gap: 12px; }
#status { flex: 1; text-align: right; color: var(--muted); }
+.choice { display: flex; align-items: flex-start; gap: 10px; padding: 8px 0; cursor: pointer; }
+.choice input { margin: 4px 0 0; accent-color: var(--accent); flex: none; }
diff --git a/src/options/options.html b/src/options/options.html
index 4cb3cb4..1ec1c4e 100644
--- a/src/options/options.html
+++ b/src/options/options.html
@@ -22,6 +22,15 @@
GTA 6 Blocker settings
+
+
Twitch
+
What to do with streams playing Grand Theft Auto (any GTA category).
+
+
+
+
Keywords
One per line, not case sensitive. Spaces also match dashes, dots and no space at all, so GTA 6 catches "GTA-6" and "gta6". Hashtags match by prefix, so GTA also catches #gtaonline.
diff --git a/src/options/options.js b/src/options/options.js
index a948063..b4ee63e 100644
--- a/src/options/options.js
+++ b/src/options/options.js
@@ -10,6 +10,8 @@
$('networkBlocking').checked = s.networkBlocking;
$('keywords').value = (s.keywords || []).join('\n');
$('allowlist').value = (s.allowlist || []).join('\n');
+ const mode = s.twitchMode === 'hide' ? 'hide' : 'warn';
+ document.querySelector('input[name="twitchMode"][value="' + mode + '"]').checked = true;
}
function flash(msg) {
@@ -21,6 +23,9 @@
$('enabled').addEventListener('change', (e) => setSettings({ enabled: e.target.checked }).then(() => flash('Saved')));
$('networkBlocking').addEventListener('change', (e) => setSettings({ networkBlocking: e.target.checked }).then(() => flash('Saved')));
+ document.querySelectorAll('input[name="twitchMode"]').forEach((r) =>
+ r.addEventListener('change', (e) => setSettings({ twitchMode: e.target.value }).then(() => flash('Saved'))));
+
$('save').addEventListener('click', async () => {
const keywords = lines($('keywords').value);
if (!keywords.length) return flash('Add at least one keyword.');
diff --git a/src/shared.js b/src/shared.js
index d183423..ea6ffd2 100644
--- a/src/shared.js
+++ b/src/shared.js
@@ -40,6 +40,9 @@
networkBlocking: true,
keywords: DEFAULT_KEYWORDS,
allowlist: [],
+ // Twitch GTA streams: 'warn' = mark red + warning before opening,
+ // 'hide' = remove them completely.
+ twitchMode: 'warn',
blockedTotal: 0
};
diff --git a/test/run-test.js b/test/run-test.js
index f4248c9..b71d715 100644
--- a/test/run-test.js
+++ b/test/run-test.js
@@ -118,12 +118,15 @@ const server = http.createServer((req, res) => {
check('github: leaving the repo via SPA navigation blocks again', await isBlocked());
await page.unrouteAll();
- // Twitch: GTA streams (any GTA category) are hidden from the sidebar and
- // directory, and a channel page streaming GTA is blocked.
+ // Twitch, "remove completely" mode: GTA streams (any GTA category) are
+ // hidden from the sidebar and directory, and a channel page streaming GTA
+ // is blocked.
await page.route('https://www.twitch.tv/**', (route) => {
const file = new URL(route.request().url()).pathname.startsWith('/directory') ? 'twitch-directory.html' : 'twitch-channel.html';
route.fulfill({ contentType: 'text/html', body: fs.readFileSync(path.join(__dirname, file)) });
});
+ check('twitch: default mode is warn', (await sw.evaluate(() => globalThis.GTA6.getSettings())).twitchMode === 'warn');
+ await sw.evaluate(() => chrome.storage.local.set({ twitchMode: 'hide' }));
await page.goto('https://www.twitch.tv/directory/all');
await page.waitForTimeout(800);
check('twitch: directory page not blocked', !(await isBlocked()));
@@ -157,8 +160,56 @@ const server = http.createServer((req, res) => {
});
await page.waitForTimeout(800);
check('twitch: switching away from GTA unblocks', !(await isBlocked()));
+
+ // Twitch, default "mark red and warn" mode.
+ await sw.evaluate(() => chrome.storage.local.set({ twitchMode: 'warn' }));
+ const warning = () => page.evaluate(() => !!document.querySelector('gta6-twitch-warning'));
+ const redFrame = () => page.evaluate(() => document.documentElement.hasAttribute('data-gta6-gta-stream'));
+ const flagged = (id) => page.$eval('#' + id, (el) => !!el.closest('[data-gta6-flagged]'));
+ await page.goto('https://www.twitch.tv/directory/all');
+ await page.waitForTimeout(800);
+ for (const id of ['sn1', 'sn2', 'c1', 'c2', 'c3', 'g1']) {
+ check('twitch warn: marks ' + id + ' red', await flagged(id));
+ check('twitch warn: does not hide ' + id, !(await twHidden(id)));
+ }
+ for (const id of ['sn3', 'c4', 'g2']) check('twitch warn: leaves ' + id + ' unmarked', !(await flagged(id)));
+ // Clicking a red stream warns before the stream is opened.
+ await page.click('#c2 a[href="/retrogamer"]');
+ await page.waitForTimeout(500);
+ check('twitch warn: click on red stream shows warning first', await warning());
+ check('twitch warn: stream not opened yet', page.url().endsWith('/directory/all'));
+ await page.click('#gta6-back');
+ await page.waitForTimeout(300);
+ check('twitch warn: "Go back" stays on the list', !(await warning()) && page.url().endsWith('/directory/all'));
+ await page.click('#c2 a[href="/retrogamer"]');
+ await page.waitForTimeout(300);
+ await Promise.all([page.waitForURL('**/retrogamer'), page.click('#gta6-continue')]);
+ await page.waitForTimeout(1000);
+ check('twitch warn: "Watch anyway" opens the stream without a second warning', !(await warning()));
+ check('twitch warn: stream shown with red frame', await redFrame());
+ check('twitch warn: stream page not blocked', !(await isBlocked()));
+ // Opening a GTA stream directly: warning in front of the paused player.
+ await page.goto('https://www.twitch.tv/rpstreamer');
+ await page.waitForTimeout(1000);
+ check('twitch warn: direct visit shows warning', await warning());
+ check('twitch warn: direct visit red frame', await redFrame());
+ await page.click('#gta6-continue');
+ await page.waitForTimeout(300);
+ check('twitch warn: "Watch anyway" removes warning', !(await warning()));
+ await page.goto('https://www.twitch.tv/blockbuilder');
+ await page.waitForTimeout(1000);
+ check('twitch warn: non-GTA stream has no warning or frame', !(await warning()) && !(await redFrame()));
await page.unrouteAll();
+ // Settings page: the Twitch choice is saved.
+ await page.goto('chrome-extension://' + new URL(sw.url()).host + '/options/options.html');
+ await page.waitForTimeout(300);
+ check('options: warn selected by default', await page.isChecked('input[name="twitchMode"][value="warn"]'));
+ await page.check('input[name="twitchMode"][value="hide"]');
+ await page.waitForTimeout(300);
+ check('options: "remove completely" saved', (await sw.evaluate(() => chrome.storage.local.get('twitchMode'))).twitchMode === 'hide');
+ await sw.evaluate(() => chrome.storage.local.set({ twitchMode: 'warn' }));
+
// Turn off and confirm content comes back.
await sw.evaluate(() => chrome.storage.local.set({ enabled: false }));
await page.waitForTimeout(300);
diff --git a/test/twitch-directory.html b/test/twitch-directory.html
index 2421c5a..cbd8478 100644
--- a/test/twitch-directory.html
+++ b/test/twitch-directory.html
@@ -11,7 +11,7 @@