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 @@
-