├── README.md └── twitchAdSkip.js /README.md: -------------------------------------------------------------------------------- 1 | # twitchAdSkip 2 | Based upon `refreshTwitchAd.js` (see credits) 3 | 4 | ## Purpose 5 | This is a script injectable by TamperMonkey (or similar) that attempts to bypass/skip Twitch's obnoxious mid-roll ad process when used in conjunction with effective adblocking filters/rules. This script itself *won't block ads* - it will skip the placeholder presented by Twitch (see purple image below under `Why`) when ads are effectively blocked. 6 | 7 | ## Requirements 8 | - Ads adblocked (preferably with [uBlock Origin](https://github.com/gorhill/uBlock) + [odensc's ttv-ublock workaround](https://github.com/odensc/ttv-ublock)) 9 | - UserScript manager browser extension, e.g. [TamperMonkey](https://www.tampermonkey.net/) or [GreaseMonkey](https://www.greasespot.net/) 10 | - [FrankerFaceZ extension](https://www.frankerfacez.com/) 11 | 12 | ## Why 13 | Twitch has very recently & noticeably ramped up its anti-adblock efforts in an effort to increase ad viewership across its platform, presumably to increase profits. 14 | There is currently a non-official workaround for blocking pre-roll (before stream starts) ads, but mid-roll ads are half blocked. Currently, mid-roll _ads_ are blocked, but a pattern is emerging that Twitch is interrupting stream viewing with a notice regarding "third party tool[s]", specifically for those with ad-blockers in an effort to dissuade their use. 15 | 16 | ![image](https://user-images.githubusercontent.com/16191979/97927844-b45ba100-1d5d-11eb-9149-b3bfcc4ee7cf.png) 17 | _(Screenshot showing Twitch notice when an adblocker is used. Because not loading ads is "impacting site performance", and watching ads results in "the best Twitch experience" /s. Blatantly lying to the consumer.)_ 18 | 19 | ## Installation (TamperMonkey, but adaptable to other UserScript managers) 20 | 1. Ensure that your browser meets the requirements above 21 | 2. Copy the [script](https://raw.githubusercontent.com/Wilkolicious/twitchAdSkip/main/twitchAdSkip.js) to your clipboard 22 | 3. Open your TamperMonkey dashboard 23 | 4. Find and press the new script button. In TamperMonkey, this can be found as a tab in the dashboard with a plus-in-a-box icon 24 | ![image](https://user-images.githubusercontent.com/16191979/97928662-6d6eab00-1d5f-11eb-9dc6-30a6d266e2dd.png) 25 | 5. Paste the code in the edit text area 26 | 6. Save (Ctrl-s works, as if saving a Spreadsheet or Document) 27 | 7. Ensure that the script is enabled, e.g. green: 28 | ![image](https://user-images.githubusercontent.com/16191979/97933577-1242b580-1d6b-11eb-8af5-018c06ed81ae.png) 29 | 8. Reload any twitch streams 30 | 9. To update, repeat steps 2-8 (except edit the saved script instead of adding a new one) 31 | 32 | ## Limitations 33 | - There will be a small delay (~1 second) when the mid-roll ad runs where the stream refreshes. Really, we need to find a way to stop the ad process before it reaches the player but that requires reverse engineering of Twitch's SPA or some undiscovered adblock rule. 34 | - The script attempts to maintain the volume level between refreshes, but there may be scenarios where it does not. 35 | - If the current adblock mechanism ([currently here](https://github.com/odensc/ttv-ublock)) no longer works, then the stream will keep refreshing until Twitch's app stops pushing an ad. 36 | - Twitch's / AWS IVS engineers are currently breaking these kinds of workarounds/bypasses often. They are likely reading the discussion threads and deliberately breaking user scipts. Hi Twitch engineer if you're reading this. 37 | 38 | ## Privacy 39 | - I don't want your data or want to handle it in any way. 40 | - The code will always remain auditable here. 41 | - I am not a nameless face/ghost like the [new owners of NanoAdblocker](https://github.com/NanoAdblocker/NanoCore/issues/362). 42 | 43 | The update URLs set in the UserScript are only for ease of use but will mean that updates are sourced directly from the latest commit in this repo. If you do not trust the code here or want automatic updates, then disable script updates in your UserScript manager and/or remove the following lines from the script in your UserScript manager: 44 | ```js 45 | // @updateURL https://raw.githubusercontent.com/Wilkolicious/twitchAdSkip/main/twitchAdSkip.js 46 | // @downloadURL https://raw.githubusercontent.com/Wilkolicious/twitchAdSkip/main/twitchAdSkip.js 47 | ``` 48 | 49 | ## Credits 50 | - [simple-hacker](https://github.com/simple-hacker) - [initial gist & continuous revisions that this is based upon](https://gist.github.com/simple-hacker/ddd81964b3e8bca47e0aead5ad19a707/) 51 | 52 | ## License 53 | MIT 54 | -------------------------------------------------------------------------------- /twitchAdSkip.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name twitchAdSkip 3 | // @namespace https://www.twitch.tv/ 4 | // @version 2.0 5 | // @description Script to skip ad placeholder (i.e. purple screen of doom when ads are blocked) 6 | // @author simple-hacker & Wilkolicious 7 | // @match https://www.twitch.tv/* 8 | // @grant none 9 | // @require https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.11.0/underscore-min.js 10 | // @homepageURL https://github.com/Wilkolicious/twitchAdSkip 11 | // @updateURL https://raw.githubusercontent.com/Wilkolicious/twitchAdSkip/main/twitchAdSkip.js 12 | // @downloadURL https://raw.githubusercontent.com/Wilkolicious/twitchAdSkip/main/twitchAdSkip.js 13 | // ==/UserScript== 14 | 15 | (function () { 16 | 'use strict'; 17 | 18 | const scriptName = 'twitchAdSkip'; 19 | const adTestSel = '[data-test-selector="ad-banner-default-text"]'; 20 | const ffzResetBtnSel = '[data-a-target="ffz-player-reset-button"]'; 21 | const videoPlayerSel = '[data-a-target="video-player"]'; 22 | const videoPlayervolSliderSel = '[data-a-target="player-volume-slider"]'; 23 | const videoNodeSel = 'video'; 24 | const postFixVolWaitTime = 2000; 25 | const nodeTypesToCheck = [Node.ELEMENT_NODE, Node.DOCUMENT_NODE, Node.DOCUMENT_FRAGMENT_NODE]; 26 | 27 | // 28 | const maxRetriesFindVideoPlayer = 5; 29 | const maxRetriesVolListener = 5; 30 | const maxRetriesVideoPlayerObserver = 5; 31 | 32 | // Volume vals 33 | let videoNodeVolCurrent; 34 | let adLaunched = false; 35 | 36 | // Helpers // 37 | const log = function (logType, message) { 38 | return console[logType](`${scriptName}: ${message}`); 39 | }; 40 | const getFfzResetButton = function () { 41 | return document.querySelector(ffzResetBtnSel); 42 | }; 43 | const getElWithOptContext = function (selStr, context) { 44 | context = context || document; 45 | return context.querySelector(selStr); 46 | }; 47 | const getVideoNodeEl = function (context) { 48 | return getElWithOptContext(videoNodeSel, context); 49 | }; 50 | const getVideoPlayerVolSliderEl = function (context) { 51 | return getElWithOptContext(videoPlayervolSliderSel, context); 52 | }; 53 | const getVideoPlayerEl = function (context) { 54 | return getElWithOptContext(videoPlayerSel, context); 55 | }; 56 | 57 | const attachMO = async function (videoPlayerEl) { 58 | let resetButton = getFfzResetButton(); 59 | 60 | const videoPlayerObserver = new MutationObserver(function (mutations) { 61 | for (const mutation of mutations) { 62 | for (const node of mutation.addedNodes) { 63 | const canCheckNode = nodeTypesToCheck.includes(node.nodeType); 64 | if (!canCheckNode) { 65 | continue; 66 | } 67 | 68 | const isAdNode = node.querySelector(adTestSel); 69 | if (!isAdNode) { 70 | continue; 71 | } 72 | 73 | log('info', `Found ad node at: ${adTestSel}`); 74 | 75 | // Is ad // 76 | adLaunched = true; 77 | if (!resetButton) { 78 | log('info', `FFZ reset button not loaded - attempting to load...`); 79 | 80 | // Attempt to load the resetButton now 81 | resetButton = getFfzResetButton(); 82 | 83 | if (!resetButton) { 84 | log('error', `FFZ reset button could not be loaded - refreshing full page.`); 85 | 86 | // Not loaded for some reason 87 | window.location.reload(); 88 | } 89 | } 90 | 91 | // Cache current vol props // 92 | log('info', 'Finding video node to post-fix volume.'); 93 | // Actual video volume 94 | const videoNodeEl = getVideoNodeEl(videoPlayerEl); 95 | log('info', `Volume before reset: ${videoNodeVolCurrent}`); 96 | 97 | // Cosmetic vol slider 98 | const videoPlayerVolSliderEl = getVideoPlayerVolSliderEl(videoPlayerEl); 99 | const videoPlayerVolSliderCurrent = parseInt(videoPlayerVolSliderEl.value, 10).toFixed(2); 100 | 101 | log('info', `Triggering FFZ reset button...`); 102 | resetButton.dispatchEvent(new MouseEvent('dblclick', { 103 | bubbles: true, 104 | cancelable: true, 105 | view: window 106 | })); 107 | 108 | log('info', `Fixing volume to original value of '${videoNodeVolCurrent}' after interval of '${postFixVolWaitTime}' ms`); 109 | setTimeout(() => { 110 | // Does the video player element still exist after reset? 111 | if (!videoPlayerEl) { 112 | log('info', 'Video player element destroyed after reset - sourcing new element...'); 113 | videoPlayerEl = getVideoPlayerEl(); 114 | } 115 | 116 | // Does the video node still exist after reset? 117 | if (!videoNodeEl) { 118 | log('info', 'Video node destroyed after reset - sourcing new node...'); 119 | videoNodeEl = getVideoNodeEl(videoPlayerEl); 120 | } 121 | 122 | // Fix video vol 123 | const preFixVol = videoNodeEl.volume; 124 | videoNodeEl.volume = videoNodeVolCurrent; 125 | log('info', `Post-fixed volume from reset val of '${preFixVol}' -> '${videoNodeVolCurrent}'`); 126 | 127 | // Fix video player vol slider 128 | // TODO: this may not work due to this input being tied to the js framework component 129 | if (!videoPlayerVolSliderEl) { 130 | videoPlayerVolSliderEl = getVideoPlayerVolSliderEl(videoPlayerEl); 131 | } 132 | videoPlayerVolSliderEl.value = videoPlayerVolSliderCurrent; 133 | 134 | adLaunched = false; 135 | }, postFixVolWaitTime); 136 | } 137 | } 138 | }); 139 | 140 | videoPlayerObserver.observe(videoPlayerEl, { 141 | childList: true, 142 | subtree: true 143 | }); 144 | log('info', 'Video player observer attached'); 145 | }; 146 | 147 | const listenForVolumeChanges = async function (videoPlayerEl) { 148 | const videoNodeEl = getVideoNodeEl(videoPlayerEl); 149 | 150 | if (!videoNodeEl) { 151 | throw new Error('Video player element not found. If it is expected that there is no video on the current page (e.g. Twitch directory), then ignore this error.'); 152 | } 153 | 154 | // Initial load val 155 | videoNodeVolCurrent = videoNodeEl.volume.toFixed(2); 156 | log('info', `Initial volume: '${videoNodeVolCurrent}'.`); 157 | 158 | const videoPlayerVolSliderEl = getVideoPlayerVolSliderEl(videoPlayerEl); 159 | 160 | if (!videoPlayerVolSliderEl) { 161 | throw new Error('Video player volume slider not found. Perhaps application is in picture-in-picture mode?'); 162 | } 163 | 164 | const setCurrentVolume = (event) => { 165 | // Ignore any vol changes for ads 166 | if (document.querySelector(adTestSel) || adLaunched) { 167 | return; 168 | } 169 | 170 | // Always find the video node element as Twitch app may have re-created tracked element 171 | videoNodeVolCurrent = getVideoNodeEl(videoPlayerEl).volume.toFixed(2); 172 | log('info', `Volume modified to: '${videoNodeVolCurrent}'.`); 173 | }; 174 | 175 | // Standard volume change listeners 176 | videoPlayerVolSliderEl.addEventListener('keyup', (event) => { 177 | if (!event.key) { 178 | return; 179 | } 180 | 181 | if (!['ArrowUp', 'ArrowDown'].includes(event.key)) { 182 | return; 183 | } 184 | setCurrentVolume(event); 185 | }); 186 | 187 | videoPlayerVolSliderEl.addEventListener('mouseup', setCurrentVolume); 188 | videoPlayerVolSliderEl.addEventListener('scroll', (event) => _.debounce(setCurrentVolume, 1000)); 189 | 190 | // TODO: FFZ scrollup & scrolldown support 191 | }; 192 | 193 | const retryWrap = function(fnToRetry, args, intervalInMs, maxRetries, actionDescription) { 194 | const retry = (fn, retries = 3) => fn() 195 | .catch((e) => { 196 | if (retries <= 0) { 197 | log('error', `${actionDescription} - failed after ${maxRetries} retries.`) 198 | return Promise.reject(e); 199 | } 200 | log('warn', `${actionDescription} - retrying another ${retries} time(s).`); 201 | return retry(fn, --retries) 202 | }); 203 | 204 | const delay = ms => new Promise((resolve) => setTimeout(resolve, ms)); 205 | const delayError = (fn, args, ms) => () => fn(...args).catch((e) => delay(ms).then((y) => Promise.reject(e))); 206 | return retry(delayError(fnToRetry, args, intervalInMs), maxRetries); 207 | }; 208 | 209 | const spawnFindVideoPlayerEl = async function() { 210 | const actionDescription = 'Finding video player'; 211 | log('info', `${actionDescription}...`); 212 | const findVideoPlayerEl = async () => { 213 | const videoPlayerEl = document.querySelector(videoPlayerSel); 214 | if (!videoPlayerEl) { 215 | return Promise.reject('Video player not found.'); 216 | } 217 | return videoPlayerEl; 218 | }; 219 | return await retryWrap(findVideoPlayerEl, [], 2000, maxRetriesFindVideoPlayer, actionDescription); 220 | }; 221 | 222 | const spawnVolumeChangeListener = async function(videoPlayerEl) { 223 | const actionDescription = 'Listening for volume changes'; 224 | log('info', `${actionDescription}...`); 225 | retryWrap(listenForVolumeChanges, [videoPlayerEl], 2000, maxRetriesVolListener, actionDescription); 226 | }; 227 | 228 | const spawnVideoPlayerAdSkipObservers = async function(videoPlayerEl) { 229 | const actionDescription = 'Attaching MO'; 230 | log('info', `${actionDescription}...`); 231 | retryWrap(attachMO, [videoPlayerEl], 2000, maxRetriesVideoPlayerObserver, actionDescription); 232 | }; 233 | 234 | const spawnObservers = async function () { 235 | try { 236 | const videoPlayerEl = await spawnFindVideoPlayerEl(); 237 | 238 | if (!videoPlayerEl) { 239 | throw new Error('Could not find video player.'); 240 | } 241 | log('info', 'Success - video player found.'); 242 | 243 | spawnVolumeChangeListener(videoPlayerEl); 244 | spawnVideoPlayerAdSkipObservers(videoPlayerEl); 245 | } catch (error) { 246 | log('error', error); 247 | } 248 | } 249 | 250 | log('info', 'Page loaded - attempting to spawn observers...'); 251 | spawnObservers(); 252 | 253 | log('info', 'Overloading history push state') 254 | var pushState = history.pushState; 255 | history.pushState = function () { 256 | pushState.apply(history, arguments); 257 | 258 | log('info', 'History change - attempting to spawn observers...') 259 | spawnObservers(); 260 | }; 261 | })(); 262 | --------------------------------------------------------------------------------