twitch-videoad.js text/javascript (function() { if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } 'use strict'; const ourTwitchAdSolutionsVersion = 32;// Used to prevent conflicts with outdated versions of the scripts if (typeof window.twitchAdSolutionsVersion !== 'undefined' && window.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { console.log("skipping vaft as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + window.twitchAdSolutionsVersion); window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; return; } window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; // Configuration and state shared between window and worker scopes function declareOptions(scope) { scope.AdSignifiers = ['stitched', 'stitched-ad', 'X-TV-TWITCH-AD', 'EXT-X-CUE-OUT', 'EXT-X-DATERANGE:CLASS="twitch-stitched-ad"', 'SCTE35-OUT']; scope.AdSegmentURLPatterns = ['/adsquared/', '/_404/', '/processing']; scope.ClientID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; scope.BackupPlayerTypes = [ 'embed',//Source 'site',//Source 'popout',//Source 'mobile_web',//Mobile 'autoplay',//360p //'picture-by-picture-CACHED'//360p (-CACHED is an internal suffix and is removed) ]; scope.FallbackPlayerType = 'embed'; scope.ForceAccessTokenPlayerType = 'popout'; scope.SkipPlayerReloadOnHevc = false;// If true this will skip player reload on streams which have 2k/4k quality (if you enable this and you use the 2k/4k quality setting you'll get error #4000 / #3000 / spinning wheel on chrome based browsers) scope.AlwaysReloadPlayerOnAd = false;// Always pause/play when entering/leaving ads scope.ReloadPlayerAfterAd = true;// After the ad finishes do a player reload instead of pause/play scope.PinBackupPlayerType = false;// If true, remember which backup player type worked and try it first on next ad break scope.PlayerReloadMinimalRequestsTime = 1500; scope.PlayerReloadMinimalRequestsPlayerIndex = 2;//autoplay scope.HasTriggeredPlayerReload = false; scope.StreamInfos = []; scope.StreamInfosByUrl = []; scope.GQLDeviceID = null; scope.ClientVersion = null; scope.ClientSession = null; scope.ClientIntegrityHeader = null; scope.AuthorizationHeader = undefined; scope.SimulatedAdsDepth = 0; scope.PlayerBufferingFix = true;// If true this will pause/play the player when it gets stuck buffering scope.PlayerBufferingDelay = 600;// How often should we check the player state (in milliseconds) scope.PlayerBufferingSameStateCount = 3;// How many times of seeing the same player state until we trigger pause/play (it will only trigger it one time until the player state changes again) scope.PlayerBufferingDangerZone = 1;// The buffering time left (in seconds) when we should ignore the players playback position in the player state check scope.PlayerBufferingDoPlayerReload = false;// If true this will do a player reload instead of pause/play (player reloading is better at fixing the playback issues but it takes slightly longer) scope.PlayerBufferingMinRepeatDelay = 8000;// Minimum delay (in milliseconds) between each pause/play (this is to avoid over pressing pause/play when there are genuine buffering problems) scope.PlayerBufferingPrerollCheckEnabled = false;// Enable this if you're getting an immediate pause/play/reload as you open a stream (which is causing the stream to take longer to load). One problem with this being true is that it can cause the player to get stuck in some instances requiring the user to press pause/play scope.PlayerBufferingPrerollCheckOffset = 5;// How far the stream need to move before doing the buffering mitigation (depends on PlayerBufferingPrerollCheckEnabled being true) scope.V2API = false; scope.IsAdStrippingEnabled = true; scope.AdSegmentCache = new Map(); scope.AllSegmentsAreAdSegments = false; } let isActivelyStrippingAds = false; let localStorageHookFailed = false; const twitchWorkers = []; let cachedRootNode = null;// Cached #root DOM element (never changes in React SPAs) let cachedPlayerRootDiv = null;// Cached .video-player element // Strings used to detect and handle conflicting Twitch worker overrides (e.g. TwitchNoSub) const workerStringConflicts = [ 'twitch', 'isVariantA'// TwitchNoSub ]; const workerStringReinsert = [ 'isVariantA',// TwitchNoSub (prior to (0.9)) 'besuper/',// TwitchNoSub (0.9) '${patch_url}'// TwitchNoSub (0.9.1) ]; // Walk the Worker prototype chain and remove conflicting overrides function getCleanWorker(worker) { let root = null; let parent = null; let proto = worker; while (proto) { const workerString = proto.toString(); if (workerStringConflicts.some((x) => workerString.includes(x))) { if (parent !== null) { Object.setPrototypeOf(parent, Object.getPrototypeOf(proto)); } } else { if (root === null) { root = proto; } parent = proto; } proto = Object.getPrototypeOf(proto); } return root; } function getWorkersForReinsert(worker) { const result = []; let proto = worker; while (proto) { const workerString = proto.toString(); if (workerStringReinsert.some((x) => workerString.includes(x))) { result.push(proto); } proto = Object.getPrototypeOf(proto); } return result; } function reinsertWorkers(worker, reinsert) { let parent = worker; for (let i = 0; i < reinsert.length; i++) { Object.setPrototypeOf(reinsert[i], parent); parent = reinsert[i]; } return parent; } function isValidWorker(worker) { const workerString = worker.toString(); const hasConflict = workerStringConflicts.some((x) => workerString.includes(x)); const hasReinsert = workerStringReinsert.some((x) => workerString.includes(x)); if (hasConflict && !hasReinsert) { console.log('[AD DEBUG] Worker rejected — conflict string found: ' + workerStringConflicts.filter((x) => workerString.includes(x)).join(', ')); } return !hasConflict || hasReinsert; } // Replace window.Worker to intercept Twitch's video worker and inject ad-blocking logic function hookWindowWorker() { const reinsert = getWorkersForReinsert(window.Worker); const newWorker = class Worker extends getCleanWorker(window.Worker) { constructor(twitchBlobUrl, options) { let isTwitchWorker = false; try { isTwitchWorker = new URL(twitchBlobUrl).origin.endsWith('.twitch.tv'); } catch {} if (!isTwitchWorker) { super(twitchBlobUrl, options); console.log('[AD DEBUG] Non-Twitch worker skipped: ' + twitchBlobUrl); return; } console.log('[AD DEBUG] Worker intercepted — injecting ad-block hooks'); const newBlobStr = ` const pendingFetchRequests = new Map(); ${hasAdTags.toString()} ${getMatchedAdSignifiers.toString()} ${stripAdSegments.toString()} ${getStreamUrlForResolution.toString()} ${processM3U8.toString()} ${hookWorkerFetch.toString()} ${declareOptions.toString()} ${getAccessToken.toString()} ${gqlRequest.toString()} ${parseAttributes.toString()} ${getWasmWorkerJs.toString()} ${getServerTimeFromM3u8.toString()} ${replaceServerTimeInM3u8.toString()} const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}'); declareOptions(self); ReloadPlayerAfterAd = ${ReloadPlayerAfterAd}; PinBackupPlayerType = ${PinBackupPlayerType}; ForceAccessTokenPlayerType = '${ForceAccessTokenPlayerType}'; GQLDeviceID = ${GQLDeviceID ? "'" + GQLDeviceID + "'" : null}; AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined}; ClientIntegrityHeader = ${ClientIntegrityHeader ? "'" + ClientIntegrityHeader + "'" : null}; ClientVersion = ${ClientVersion ? "'" + ClientVersion + "'" : null}; ClientSession = ${ClientSession ? "'" + ClientSession + "'" : null}; self.addEventListener('message', function(e) { if (e.data.key == 'UpdateClientVersion') { ClientVersion = e.data.value; } else if (e.data.key == 'UpdateClientSession') { ClientSession = e.data.value; } else if (e.data.key == 'UpdateClientId') { ClientID = e.data.value; } else if (e.data.key == 'UpdateDeviceId') { GQLDeviceID = e.data.value; } else if (e.data.key == 'UpdateClientIntegrityHeader') { ClientIntegrityHeader = e.data.value; } else if (e.data.key == 'UpdateAuthorizationHeader') { AuthorizationHeader = e.data.value; } else if (e.data.key == 'FetchResponse') { const responseData = e.data.value; if (pendingFetchRequests.has(responseData.id)) { const { resolve, reject } = pendingFetchRequests.get(responseData.id); pendingFetchRequests.delete(responseData.id); if (responseData.error) { reject(new Error(responseData.error)); } else { // Create a Response object from the response data const response = new Response(responseData.body, { status: responseData.status, statusText: responseData.statusText, headers: responseData.headers }); resolve(response); } } } else if (e.data.key == 'TriggeredPlayerReload') { HasTriggeredPlayerReload = true; } else if (e.data.key == 'SimulateAds') { SimulatedAdsDepth = e.data.value; console.log('SimulatedAdsDepth: ' + SimulatedAdsDepth); } else if (e.data.key == 'AllSegmentsAreAdSegments') { AllSegmentsAreAdSegments = !AllSegmentsAreAdSegments; console.log('AllSegmentsAreAdSegments: ' + AllSegmentsAreAdSegments); } }); hookWorkerFetch(); eval(workerString); `; super(URL.createObjectURL(new Blob([newBlobStr])), options); twitchWorkers.length = 0; twitchWorkers.push(this); this.addEventListener('message', (e) => { if (e.data.key == 'UpdateAdBlockBanner') { updateAdblockBanner(e.data); } else if (e.data.key == 'PauseResumePlayer') { doTwitchPlayerTask(true, false); } else if (e.data.key == 'ReloadPlayer') { doTwitchPlayerTask(false, true); } }); this.addEventListener('message', async event => { if (event.data.key == 'FetchRequest') { const fetchRequest = event.data.value; const responseData = await handleWorkerFetchRequest(fetchRequest); this.postMessage({ key: 'FetchResponse', value: responseData }); } }); } }; let workerInstance = reinsertWorkers(newWorker, reinsert); Object.defineProperty(window, 'Worker', { get: function() { return workerInstance; }, set: function(value) { if (isValidWorker(value)) { workerInstance = value; } else { console.log('Attempt to set twitch worker denied'); } } }); } function getWasmWorkerJs(twitchBlobUrl) { const req = new XMLHttpRequest(); req.open('GET', twitchBlobUrl, false); req.overrideMimeType("text/javascript"); req.send(); return req.responseText; } // Hook fetch() in the worker scope to intercept m3u8 playlist requests and ad segments function hookWorkerFetch() { console.log('hookWorkerFetch (vaft)'); const BLANK_MP4 = new Blob([Uint8Array.from(atob('AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA'), c => c.charCodeAt(0))], {type: 'video/mp4'}); const realFetch = fetch; fetch = async function(url, options) { if (typeof url === 'string') { if (AdSegmentCache.has(url)) { return new Response(BLANK_MP4); } url = url.trimEnd(); if (url.endsWith('m3u8')) { return new Promise(function(resolve, reject) { const processAfter = async function(response) { if (response.status === 200) { resolve(new Response(await processM3U8(url, await response.text(), realFetch))); } else { resolve(response); } }; realFetch(url, options).then(function(response) { processAfter(response); })['catch'](function(err) { reject(err); }); }); } else if (url.includes('/channel/hls/') && !url.includes('picture-by-picture')) { V2API = url.includes('/api/v2/'); const parsedUrl = new URL(url); const channelName = parsedUrl.pathname.match(/([^\/]+)(?=\.\w+$)/)?.[0]; if (ForceAccessTokenPlayerType) { // parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads parsedUrl.searchParams.delete('parent_domains'); url = parsedUrl.toString(); } return new Promise(function(resolve, reject) { const processAfter = async function(response) { if (response.status == 200) { const encodingsM3u8 = await response.text(); const serverTime = getServerTimeFromM3u8(encodingsM3u8); let streamInfo = StreamInfos[channelName]; if (streamInfo != null && streamInfo.EncodingsM3U8 != null && (await realFetch(streamInfo.EncodingsM3U8.match(/^https:.*\.m3u8$/m)?.[0])).status !== 200) { // The cached encodings are dead (the stream probably restarted) streamInfo = null; } if (streamInfo == null || streamInfo.EncodingsM3U8 == null) { console.log('[AD DEBUG] New stream session — channel: ' + channelName + ', API: ' + (V2API ? 'v2' : 'v1')); StreamInfos[channelName] = streamInfo = { ChannelName: channelName, IsShowingAd: false, LastPlayerReload: 0, EncodingsM3U8: encodingsM3u8, ModifiedM3U8: null, IsUsingModifiedM3U8: false, UsherParams: parsedUrl.search, RequestedAds: new Set(), Urls: [],// xxx.m3u8 -> { Resolution: "284x160", FrameRate: 30.0 } ResolutionList: [], BackupEncodingsM3U8Cache: [], ActiveBackupPlayerType: null, PinnedBackupPlayerType: null, HasCheckedUnknownTags: false, IsMidroll: false, IsStrippingAdSegments: false, NumStrippedAdSegments: 0, RecoverySegments: [], FailedBackupPlayerTypes: new Set() }; const lines = encodingsM3u8.split(/\r?\n/); for (let i = 0; i < lines.length - 1; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) { const attributes = parseAttributes(lines[i]); const resolution = attributes['RESOLUTION']; if (resolution) { const resolutionInfo = { Resolution: resolution, FrameRate: attributes['FRAME-RATE'], Codecs: attributes['CODECS'], Url: lines[i + 1] }; streamInfo.Urls[lines[i + 1]] = resolutionInfo; streamInfo.ResolutionList.push(resolutionInfo); } StreamInfosByUrl[lines[i + 1]] = streamInfo; } } if (streamInfo.ResolutionList.length === 0) { console.log('[AD DEBUG] No resolutions parsed from encodings m3u8 — Twitch may have changed the format'); } const nonHevcResolutionList = streamInfo.ResolutionList.filter((element) => element.Codecs.startsWith('avc') || element.Codecs.startsWith('av0')); if (AlwaysReloadPlayerOnAd || (nonHevcResolutionList.length > 0 && streamInfo.ResolutionList.some((element) => element.Codecs.startsWith('hev') || element.Codecs.startsWith('hvc')) && !SkipPlayerReloadOnHevc)) { if (nonHevcResolutionList.length > 0) { for (let i = 0; i < lines.length - 1; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF')) { const resSettings = parseAttributes(lines[i].substring(lines[i].indexOf(':') + 1)); const codecsKey = 'CODECS'; if (resSettings[codecsKey].startsWith('hev') || resSettings[codecsKey].startsWith('hvc')) { const oldResolution = resSettings['RESOLUTION']; const [targetWidth, targetHeight] = oldResolution.split('x').map(Number); const newResolutionInfo = nonHevcResolutionList.sort((a, b) => { // TODO: Take into account 'Frame-Rate' when sorting (i.e. 1080p60 vs 1080p30) const [streamWidthA, streamHeightA] = a.Resolution.split('x').map(Number); const [streamWidthB, streamHeightB] = b.Resolution.split('x').map(Number); return Math.abs((streamWidthA * streamHeightA) - (targetWidth * targetHeight)) - Math.abs((streamWidthB * streamHeightB) - (targetWidth * targetHeight)); })[0]; console.log('ModifiedM3U8 swap ' + resSettings[codecsKey] + ' to ' + newResolutionInfo.Codecs + ' oldRes:' + oldResolution + ' newRes:' + newResolutionInfo.Resolution); lines[i] = lines[i].replace(/CODECS="[^"]+"/, `CODECS="${newResolutionInfo.Codecs}"`); lines[i + 1] = newResolutionInfo.Url + ' '.repeat(i + 1);// The stream doesn't load unless each url line is unique } } } } if (nonHevcResolutionList.length > 0 || AlwaysReloadPlayerOnAd) { streamInfo.ModifiedM3U8 = lines.join('\n'); } } } streamInfo.LastPlayerReload = Date.now(); resolve(new Response(replaceServerTimeInM3u8(streamInfo.IsUsingModifiedM3U8 ? streamInfo.ModifiedM3U8 : streamInfo.EncodingsM3U8, serverTime))); } else { resolve(response); } }; realFetch(url, options).then(function(response) { processAfter(response); })['catch'](function(err) { reject(err); }); }); } } return realFetch.apply(this, arguments); }; } function getServerTimeFromM3u8(encodingsM3u8) { if (V2API) { const matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/); return matches && matches.length > 1 ? matches[1] : null; } const matches = encodingsM3u8.match(/SERVER-TIME="([0-9.]+)"/); return matches && matches.length > 1 ? matches[1] : null; } function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) { if (V2API) { return newServerTime ? encodingsM3u8.replace(/(#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE=")[^"]+(")/, `$1${newServerTime}$2`) : encodingsM3u8; } return newServerTime ? encodingsM3u8.replace(/(SERVER-TIME=")[0-9.]+"/, `SERVER-TIME="${newServerTime}"`) : encodingsM3u8; } function hasAdTags(textStr) { return AdSignifiers.some((s) => textStr.includes(s)); } function getMatchedAdSignifiers(textStr) { return AdSignifiers.filter((s) => textStr.includes(s)); } // Remove ad segments from an m3u8 playlist and cache their URLs for replacement function stripAdSegments(textStr, stripAllSegments, streamInfo) { let hasStrippedAdSegments = false; let inCueOut = false; const liveSegments = []; const lines = textStr.split(/\r?\n/); const newAdUrl = 'https://twitch.tv'; for (let i = 0; i < lines.length; i++) { let line = lines[i]; // Track SCTE-35 CUE-OUT/CUE-IN ad boundaries if (line.includes('EXT-X-CUE-OUT')) { if (!inCueOut) { console.log('[AD DEBUG] SCTE-35 CUE-OUT — ad boundary entered'); } inCueOut = true; } else if (line.includes('EXT-X-CUE-IN')) { if (inCueOut) { console.log('[AD DEBUG] SCTE-35 CUE-IN — ad boundary exited'); } inCueOut = false; } // Remove tracking urls which appear in the overlay UI lines[i] = line .replaceAll(/(X-TV-TWITCH-AD-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`) .replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`); if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments || inCueOut)) { const segmentUrl = lines[i + 1]; if (!AdSegmentCache.has(segmentUrl)) { streamInfo.NumStrippedAdSegments++; } AdSegmentCache.set(segmentUrl, Date.now()); hasStrippedAdSegments = true; } else if (i < lines.length - 1 && line.startsWith('#EXTINF') && AdSegmentURLPatterns.some((p) => lines[i + 1].includes(p))) { console.log('[AD DEBUG] Ad segment detected via URL pattern: ' + lines[i + 1]); AdSegmentCache.set(lines[i + 1], Date.now()); hasStrippedAdSegments = true; streamInfo.NumStrippedAdSegments++; } else if (i < lines.length - 1 && line.startsWith('#EXTINF') && line.includes(',live')) { liveSegments.push({ extinf: line, url: lines[i + 1] }); } if (AdSignifiers.some((s) => line.includes(s))) { hasStrippedAdSegments = true; } } if (hasStrippedAdSegments) { for (let i = 0; i < lines.length; i++) { // No low latency during ads (otherwise it's possible for the player to prefetch and display ad segments) if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { lines[i] = ''; } } } else { streamInfo.NumStrippedAdSegments = 0; } // Cache live segments for recovery if (liveSegments.length > 0) { streamInfo.RecoverySegments = liveSegments.slice(-6); } // If all segments were stripped, restore cached recovery segments to prevent black screen if (hasStrippedAdSegments && liveSegments.length === 0 && streamInfo.RecoverySegments && streamInfo.RecoverySegments.length > 0) { console.log('[AD DEBUG] All segments stripped — restoring ' + streamInfo.RecoverySegments.length + ' recovery segments'); for (let j = 0; j < streamInfo.RecoverySegments.length; j++) { lines.push(streamInfo.RecoverySegments[j].extinf); lines.push(streamInfo.RecoverySegments[j].url); } } streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; const now = Date.now(); AdSegmentCache.forEach((value, key, map) => { if (value < now - 120000) { map.delete(key); } }); return lines.join('\n'); } // Find the closest matching stream URL for a given resolution from a master m3u8 function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) { const encodingsLines = encodingsM3u8.split(/\r?\n/); const [targetWidth, targetHeight] = resolutionInfo.Resolution.split('x').map(Number); let matchedResolutionUrl = null; let matchedFrameRate = false; let closestResolutionUrl = null; let closestResolutionDifference = Infinity; for (let i = 0; i < encodingsLines.length - 1; i++) { if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) { const attributes = parseAttributes(encodingsLines[i]); const resolution = attributes['RESOLUTION']; const frameRate = attributes['FRAME-RATE']; if (resolution) { if (resolution == resolutionInfo.Resolution && (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) { matchedResolutionUrl = encodingsLines[i + 1]; matchedFrameRate = frameRate == resolutionInfo.FrameRate; if (matchedFrameRate) { return matchedResolutionUrl; } } const [width, height] = resolution.split('x').map(Number); const difference = Math.abs((width * height) - (targetWidth * targetHeight)); if (difference < closestResolutionDifference) { closestResolutionUrl = encodingsLines[i + 1]; closestResolutionDifference = difference; } } } } return closestResolutionUrl; } // Core ad-blocking logic: detect ads in m3u8, fetch backup streams, strip ad segments async function processM3U8(url, textStr, realFetch) { const streamInfo = StreamInfosByUrl[url]; if (!streamInfo) { return textStr; } if (HasTriggeredPlayerReload) { HasTriggeredPlayerReload = false; streamInfo.LastPlayerReload = Date.now(); } if (!streamInfo.HasCheckedUnknownTags) { streamInfo.HasCheckedUnknownTags = true; const unknownAdTags = textStr.match(/#EXT[^:\n]*(?:ad|cue|scte|sponsor)[^:\n]*/gi); if (unknownAdTags) { const unknown = unknownAdTags.filter(t => !AdSignifiers.some(s => t.includes(s))); if (unknown.length > 0) { console.log('[AD DEBUG] Unknown ad-related tags found: ' + [...new Set(unknown)].join(', ')); } } } const haveAdTags = hasAdTags(textStr) || SimulatedAdsDepth > 0; if (haveAdTags) { streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); if (!streamInfo.IsShowingAd) { streamInfo.IsShowingAd = true; console.log('[AD DEBUG] Ad detected — type: ' + (streamInfo.IsMidroll ? 'midroll' : 'preroll') + ', channel: ' + streamInfo.ChannelName + ', signifiers: ' + getMatchedAdSignifiers(textStr).join(', ')); postMessage({ key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, isStrippingAdSegments: false }); } if (!streamInfo.IsMidroll) { const lines = textStr.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.startsWith('#EXTINF') && lines.length > i + 1) { if (!line.includes(',live') && !streamInfo.RequestedAds.has(lines[i + 1])) { // Only request one .ts file per .m3u8 request to avoid making too many requests //console.log('Fetch ad .ts file'); streamInfo.RequestedAds.add(lines[i + 1]); fetch(lines[i + 1]).then((response)=>{response.blob()}).catch(()=>{}); break; } } } } const currentResolution = streamInfo.Urls[url]; if (!currentResolution) { console.log('Ads will leak due to missing resolution info for ' + url); return stripAdSegments(textStr, false, streamInfo); } const isHevc = currentResolution.Codecs.startsWith('hev') || currentResolution.Codecs.startsWith('hvc'); if (((isHevc && !SkipPlayerReloadOnHevc) || AlwaysReloadPlayerOnAd) && streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) { streamInfo.IsUsingModifiedM3U8 = true; streamInfo.LastPlayerReload = Date.now(); postMessage({ key: 'ReloadPlayer' }); } const backupSearchStart = Date.now(); let backupPlayerType = null; let backupM3u8 = null; let fallbackM3u8 = null; let startIndex = 0; let isDoingMinimalRequests = false; if (streamInfo.LastPlayerReload > Date.now() - PlayerReloadMinimalRequestsTime) { // When doing player reload there are a lot of requests which causes the backup stream to load in slow. Briefly prefer using a single version to prevent long delays startIndex = PlayerReloadMinimalRequestsPlayerIndex; isDoingMinimalRequests = true; } // Try pinned backup player type first if available const playerTypesToTry = [...BackupPlayerTypes]; if (PinBackupPlayerType && streamInfo.PinnedBackupPlayerType) { const pinnedIndex = playerTypesToTry.indexOf(streamInfo.PinnedBackupPlayerType); if (pinnedIndex > 0) { playerTypesToTry.splice(pinnedIndex, 1); playerTypesToTry.unshift(streamInfo.PinnedBackupPlayerType); } } for (let playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < playerTypesToTry.length; playerTypeIndex++) { const playerType = playerTypesToTry[playerTypeIndex]; const realPlayerType = playerType.replace('-CACHED', ''); if (streamInfo.FailedBackupPlayerTypes.has(realPlayerType)) { continue; } const isFullyCachedPlayerType = playerType != realPlayerType; for (let i = 0; i < 2; i++) { // This caches the m3u8 if it doesn't have ads. If the already existing cache has ads it fetches a new version (second loop) let isFreshM3u8 = false; let encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType]; if (!encodingsM3u8) { isFreshM3u8 = true; try { const accessTokenResponse = await getAccessToken(streamInfo.ChannelName, realPlayerType); if (accessTokenResponse.status === 200) { const accessToken = await accessTokenResponse.json(); if (!accessToken?.data?.streamPlaybackAccessToken) { console.log('[AD DEBUG] GQL response format changed — missing data.streamPlaybackAccessToken for ' + realPlayerType + '. Response keys: ' + JSON.stringify(Object.keys(accessToken?.data || accessToken || {}))); continue; } const urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams); urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); const encodingsM3u8Response = await realFetch(urlInfo.href); if (encodingsM3u8Response.status === 200) { encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType] = await encodingsM3u8Response.text(); } } else { console.log('[AD DEBUG] Access token HTTP ' + accessTokenResponse.status + ' for ' + realPlayerType); streamInfo.FailedBackupPlayerTypes.add(realPlayerType); } } catch (err) { console.log('[AD DEBUG] Access token failed for ' + realPlayerType + ': ' + err.message); streamInfo.FailedBackupPlayerTypes.add(realPlayerType); } } if (encodingsM3u8) { try { const streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, currentResolution); const streamM3u8Response = await realFetch(streamM3u8Url); if (streamM3u8Response.status == 200) { const m3u8Text = await streamM3u8Response.text(); if (m3u8Text) { if (playerType == FallbackPlayerType) { fallbackM3u8 = m3u8Text; } if ((!hasAdTags(m3u8Text) && (SimulatedAdsDepth == 0 || playerTypeIndex >= SimulatedAdsDepth - 1)) || (!fallbackM3u8 && playerTypeIndex >= playerTypesToTry.length - 1)) { backupPlayerType = playerType; backupM3u8 = m3u8Text; break; } if (hasAdTags(m3u8Text)) { console.log('[AD DEBUG] Backup stream (' + playerType + ') also has ads'); } if (isFullyCachedPlayerType) { break; } if (isDoingMinimalRequests) { backupPlayerType = playerType; backupM3u8 = m3u8Text; break; } } } else { console.log('[AD DEBUG] Backup stream fetch failed for ' + playerType + ' (status ' + streamM3u8Response.status + ')'); } } catch (err) { console.log('[AD DEBUG] Backup stream error for ' + playerType + ': ' + err.message); } } streamInfo.BackupEncodingsM3U8Cache[playerType] = null; if (isFreshM3u8) { break; } } } if (!backupM3u8 && fallbackM3u8) { backupPlayerType = FallbackPlayerType; backupM3u8 = fallbackM3u8; } if (backupM3u8) { textStr = backupM3u8; if (streamInfo.ActiveBackupPlayerType != backupPlayerType) { streamInfo.ActiveBackupPlayerType = backupPlayerType; if (PinBackupPlayerType) { streamInfo.PinnedBackupPlayerType = backupPlayerType; } console.log(`Blocking${(streamInfo.IsMidroll ? ' midroll ' : ' ')}ads (${backupPlayerType}) — backup found in ${Date.now() - backupSearchStart}ms`); } } else { console.log('[AD DEBUG] No ad-free backup stream found — ads may leak. Tried: ' + playerTypesToTry.slice(startIndex).join(', ')); } // TODO: Improve hevc stripping. It should always strip when there is a codec mismatch (both ways) const stripHevc = isHevc && streamInfo.ModifiedM3U8; if (IsAdStrippingEnabled || stripHevc) { textStr = stripAdSegments(textStr, stripHevc, streamInfo); } else if (!backupM3u8) { console.log('[AD DEBUG] Ad stripping disabled and no backup — ads WILL show'); } } else if (streamInfo.IsShowingAd) { console.log('Finished blocking ads — stripped ' + streamInfo.NumStrippedAdSegments + ' ad segments'); streamInfo.IsShowingAd = false; streamInfo.IsStrippingAdSegments = false; streamInfo.NumStrippedAdSegments = 0; streamInfo.ActiveBackupPlayerType = null; streamInfo.RequestedAds.clear(); streamInfo.FailedBackupPlayerTypes.clear(); if (streamInfo.IsUsingModifiedM3U8 || ReloadPlayerAfterAd) { streamInfo.IsUsingModifiedM3U8 = false; streamInfo.LastPlayerReload = Date.now(); postMessage({ key: 'ReloadPlayer' }); } else { postMessage({ key: 'PauseResumePlayer' }); } } postMessage({ key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, isStrippingAdSegments: streamInfo.IsStrippingAdSegments, numStrippedAdSegments: streamInfo.NumStrippedAdSegments, activeBackupPlayerType: streamInfo.ActiveBackupPlayerType }); return textStr; } function parseAttributes(str) { return Object.fromEntries( str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) .filter(Boolean) .map(x => { const idx = x.indexOf('='); const key = x.substring(0, idx); const value = x.substring(idx + 1); const num = Number(value); return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num]; })); } // Request a playback access token from Twitch GQL using the given player type function getAccessToken(channelName, playerType) { const body = { operationName: 'PlaybackAccessToken', variables: { isLive: true, login: channelName, isVod: false, vodID: "", playerType: playerType, platform: playerType == 'autoplay' ? 'android' : 'web' }, extensions: { persistedQuery: { version:1, sha256Hash:"ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9" } } }; return gqlRequest(body, playerType); } // Send a GQL request to Twitch via the main thread (workers can't make credentialed requests) function gqlRequest(body, playerType) { if (!GQLDeviceID) { GQLDeviceID = ''; const dcharacters = 'abcdefghijklmnopqrstuvwxyz0123456789'; const dcharactersLength = dcharacters.length; for (let i = 0; i < 32; i++) { GQLDeviceID += dcharacters.charAt(Math.floor(Math.random() * dcharactersLength)); } } let headers = { 'Client-ID': ClientID, 'X-Device-Id': GQLDeviceID, 'Authorization': AuthorizationHeader, ...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader}), ...(ClientVersion && {'Client-Version': ClientVersion}), ...(ClientSession && {'Client-Session-Id': ClientSession}) }; return new Promise((resolve, reject) => { const requestId = Math.random().toString(36).substring(2, 15); const fetchRequest = { id: requestId, url: 'https://gql.twitch.tv/gql', options: { method: 'POST', body: JSON.stringify(body), headers } }; pendingFetchRequests.set(requestId, { resolve, reject }); postMessage({ key: 'FetchRequest', value: fetchRequest }); }); } let playerForMonitoringBuffering = null; const playerBufferState = { channelName: null, hasStreamStarted: false, position: 0, bufferedPosition: 0, bufferDuration: 0, numSame: 0, fixAttempts: 0, lastFixTime: 0, isLive: true }; // Poll the player state to detect and fix buffering caused by ad stream switching function monitorPlayerBuffering() { if (playerForMonitoringBuffering) { try { const player = playerForMonitoringBuffering.player; const state = playerForMonitoringBuffering.state; if (!player.core) { playerForMonitoringBuffering = null; } else if (state.props?.content?.type === 'live' && !player.isPaused() && !player.getHTMLVideoElement()?.ended && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds) { const m3u8Url = player.core?.state?.path; if (m3u8Url) { const lastSlash = m3u8Url.lastIndexOf('/'); const queryStart = m3u8Url.indexOf('?', lastSlash); const fileName = m3u8Url.substring(lastSlash + 1, queryStart !== -1 ? queryStart : undefined); if (fileName?.endsWith('.m3u8')) { const channelName = fileName.slice(0, -5); if (playerBufferState.channelName != channelName) { playerBufferState.channelName = channelName; playerBufferState.hasStreamStarted = false; playerBufferState.numSame = 0; //console.log('Channel changed to ' + channelName); } } } if (player.getState() === 'Playing') { playerBufferState.hasStreamStarted = true; } const position = player.core?.state?.position; const bufferedPosition = player.core?.state?.bufferedPosition; const bufferDuration = player.getBufferDuration(); if (position !== undefined && bufferedPosition !== undefined) { //console.log('position:' + position + ' bufferDuration:' + bufferDuration + ' bufferPosition:' + bufferedPosition + ' state: ' + player.core?.state?.state + ' started: ' + playerBufferState.hasStreamStarted); // NOTE: This could be improved. It currently lets the player fully eat the full buffer before it triggers pause/play if (playerBufferState.hasStreamStarted && (!PlayerBufferingPrerollCheckEnabled || position > PlayerBufferingPrerollCheckOffset) && (playerBufferState.position == position || bufferDuration < PlayerBufferingDangerZone) && playerBufferState.bufferedPosition == bufferedPosition && playerBufferState.bufferDuration >= bufferDuration && (position != 0 || bufferedPosition != 0 || bufferDuration != 0) ) { playerBufferState.numSame++; if (playerBufferState.numSame == PlayerBufferingSameStateCount) { playerBufferState.fixAttempts++; const escalateToReload = playerBufferState.fixAttempts >= 3; console.log('Attempt to fix buffering position:' + playerBufferState.position + ' bufferedPosition:' + playerBufferState.bufferedPosition + ' bufferDuration:' + playerBufferState.bufferDuration + (escalateToReload ? ' (escalating to reload)' : '')); const isPausePlay = escalateToReload ? false : !PlayerBufferingDoPlayerReload; const isReload = escalateToReload ? true : PlayerBufferingDoPlayerReload; doTwitchPlayerTask(isPausePlay, isReload); playerBufferState.lastFixTime = Date.now(); playerBufferState.numSame = 0; if (escalateToReload) { playerBufferState.fixAttempts = 0; } } } else { playerBufferState.numSame = 0; playerBufferState.fixAttempts = 0; } playerBufferState.position = position; playerBufferState.bufferedPosition = bufferedPosition; playerBufferState.bufferDuration = bufferDuration; } else { playerBufferState.numSame = 0; } } } catch (err) { console.error('error when monitoring player for buffering: ' + err); playerForMonitoringBuffering = null; } } if (!playerForMonitoringBuffering) { const playerAndState = getPlayerAndState(); if (playerAndState && playerAndState.player && playerAndState.state) { playerForMonitoringBuffering = { player: playerAndState.player, state: playerAndState.state }; } } const isLive = playerForMonitoringBuffering?.state?.props?.content?.type === 'live'; if (playerBufferState.isLive && !isLive) { updateAdblockBanner({ hasAds: false }); } playerBufferState.isLive = isLive; setTimeout(monitorPlayerBuffering, PlayerBufferingDelay); } function updateAdblockBanner(data) { if (!cachedPlayerRootDiv || !cachedPlayerRootDiv.isConnected) { cachedPlayerRootDiv = document.querySelector('.video-player'); } const playerRootDiv = cachedPlayerRootDiv; if (playerRootDiv != null) { let adBlockDiv = null; adBlockDiv = playerRootDiv.querySelector('.tas-adblock-overlay'); if (adBlockDiv == null) { adBlockDiv = document.createElement('div'); adBlockDiv.className = 'tas-adblock-overlay'; adBlockDiv.innerHTML = '

'; adBlockDiv.style.display = 'none'; adBlockDiv.P = adBlockDiv.querySelector('p'); playerRootDiv.appendChild(adBlockDiv); } if (adBlockDiv != null) { isActivelyStrippingAds = data.isStrippingAdSegments; adBlockDiv.P.textContent = 'Blocking' + (data.isMidroll ? ' midroll' : '') + ' ads' + (data.isStrippingAdSegments ? ' (stripping)' : '') + (data.activeBackupPlayerType ? ' (' + data.activeBackupPlayerType + ')' : ''); adBlockDiv.style.display = data.hasAds && playerBufferState.isLive ? 'block' : 'none'; } } } // Traverse React's fiber tree to find Twitch's player and player state instances function getPlayerAndState() { function findReactNode(root, constraint) { if (root.stateNode && constraint(root.stateNode)) { return root.stateNode; } let node = root.child; while (node) { const result = findReactNode(node, constraint); if (result) { return result; } node = node.sibling; } return null; } function findReactRootNode() { let reactRootNode = null; if (!cachedRootNode) { cachedRootNode = document.querySelector('#root'); } const rootNode = cachedRootNode; if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { reactRootNode = rootNode._reactRootContainer._internalRoot.current; } if (reactRootNode == null && rootNode != null) { const containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer')); if (containerName != null) { reactRootNode = rootNode[containerName]; } } return reactRootNode; } const reactRootNode = findReactRootNode(); if (!reactRootNode) { console.log('[AD DEBUG] React root node not found — Twitch may have changed their React setup'); return null; } let player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; if (player?.playerInstance) { player = player.playerInstance; } const playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); if (!player) { console.log('[AD DEBUG] Player not found — Twitch may have renamed setPlayerActive/mediaPlayerInstance'); } if (!playerState) { console.log('[AD DEBUG] Player state not found — Twitch may have renamed setSrc/setInitialPlaybackSettings'); } return { player: player, state: playerState }; } // Pause/play or fully reload the Twitch player, preserving quality/volume settings function doTwitchPlayerTask(isPausePlay, isReload) { const playerAndState = getPlayerAndState(); if (!playerAndState) { console.log('Could not find react root'); return; } const player = playerAndState.player; const playerState = playerAndState.state; if (!player) { console.log('Could not find player'); return; } if (!playerState) { console.log('Could not find player state'); return; } if (player.isPaused() || player.core?.paused) { return; } playerBufferState.lastFixTime = Date.now(); playerBufferState.numSame = 0; if (isPausePlay) { player.pause(); player.play(); return; } if (isReload) { const lsKeyQuality = 'video-quality'; const lsKeyMuted = 'video-muted'; const lsKeyVolume = 'volume'; let currentQualityLS = null; let currentMutedLS = null; let currentVolumeLS = null; try { currentQualityLS = localStorage.getItem(lsKeyQuality); currentMutedLS = localStorage.getItem(lsKeyMuted); currentVolumeLS = localStorage.getItem(lsKeyVolume); if (localStorageHookFailed && player?.core?.state) { localStorage.setItem(lsKeyMuted, JSON.stringify({default:player.core.state.muted})); localStorage.setItem(lsKeyVolume, player.core.state.volume); } if (localStorageHookFailed && player?.core?.state?.quality?.group) { localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group})); } } catch {} console.log('Reloading Twitch player'); playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true }); postTwitchWorkerMessage('TriggeredPlayerReload'); player.play(); // Always restore muted/volume state after reload — Chrome autoplay policy can force muted if (currentQualityLS || currentMutedLS || currentVolumeLS) { setTimeout(() => { try { if (currentQualityLS) { localStorage.setItem(lsKeyQuality, currentQualityLS); } if (currentMutedLS) { localStorage.setItem(lsKeyMuted, currentMutedLS); } if (currentVolumeLS) { localStorage.setItem(lsKeyVolume, currentVolumeLS); } const videos = document.getElementsByTagName('video'); if (videos.length > 0 && videos[0].muted) { videos[0].muted = false; } // Correct live drift after reload if (videos.length > 0 && videos[0].buffered.length > 0 && videos[0].readyState >= 3) { const liveEdge = videos[0].buffered.end(videos[0].buffered.length - 1); const drift = liveEdge - videos[0].currentTime; if (drift > 2) { console.log('[AD DEBUG] Post-reload live drift correction: ' + drift.toFixed(1) + 's behind'); videos[0].currentTime = liveEdge - 0.5; } } } catch {} }, 3000); } return; } } window.reloadTwitchPlayer = () => { doTwitchPlayerTask(false, true); }; function postTwitchWorkerMessage(key, value) { twitchWorkers.forEach((worker) => { worker.postMessage({key: key, value: value}); }); } async function handleWorkerFetchRequest(fetchRequest) { try { const response = await window.realFetch(fetchRequest.url, fetchRequest.options); const responseBody = await response.text(); const responseObject = { id: fetchRequest.id, status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()), body: responseBody }; return responseObject; } catch (error) { return { id: fetchRequest.id, error: error.message }; } } // Hook fetch() in the window scope to capture auth headers and modify player type requests function hookFetch() { console.log('[AD DEBUG] Window fetch hook installed'); let hasLoggedHeaders = false; const realFetch = window.fetch; window.realFetch = realFetch; window.fetch = function(url, init, ...args) { if (typeof url === 'string') { if (url.includes('gql')) { let deviceId = init.headers['X-Device-Id']; if (typeof deviceId !== 'string') { deviceId = init.headers['Device-ID']; } if (typeof deviceId === 'string' && GQLDeviceID != deviceId) { GQLDeviceID = deviceId; postTwitchWorkerMessage('UpdateDeviceId', GQLDeviceID); } if (typeof init.headers['Client-Version'] === 'string' && init.headers['Client-Version'] !== ClientVersion) { postTwitchWorkerMessage('UpdateClientVersion', ClientVersion = init.headers['Client-Version']); } if (typeof init.headers['Client-Session-Id'] === 'string' && init.headers['Client-Session-Id'] !== ClientSession) { postTwitchWorkerMessage('UpdateClientSession', ClientSession = init.headers['Client-Session-Id']); } if (typeof init.headers['Client-Integrity'] === 'string' && init.headers['Client-Integrity'] !== ClientIntegrityHeader) { postTwitchWorkerMessage('UpdateClientIntegrityHeader', ClientIntegrityHeader = init.headers['Client-Integrity']); } if (typeof init.headers['Authorization'] === 'string' && init.headers['Authorization'] !== AuthorizationHeader) { postTwitchWorkerMessage('UpdateAuthorizationHeader', AuthorizationHeader = init.headers['Authorization']); } if (!hasLoggedHeaders && GQLDeviceID && AuthorizationHeader) { hasLoggedHeaders = true; console.log('[AD DEBUG] GQL headers captured — DeviceId: ' + (GQLDeviceID ? 'yes' : 'no') + ', Auth: ' + (AuthorizationHeader ? 'yes' : 'no') + ', Integrity: ' + (ClientIntegrityHeader ? 'yes' : 'no')); } // Get rid of mini player above chat - TODO: Reject this locally instead of having server reject it if (init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && init.body.includes('picture-by-picture')) { init.body = ''; } if (ForceAccessTokenPlayerType && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { let replacedPlayerType = ''; const newBody = JSON.parse(init.body); if (Array.isArray(newBody)) { for (let i = 0; i < newBody.length; i++) { if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType !== ForceAccessTokenPlayerType) { replacedPlayerType = newBody[i].variables.playerType; newBody[i].variables.playerType = ForceAccessTokenPlayerType; } } } else { if (newBody?.variables?.playerType && newBody?.variables?.playerType !== ForceAccessTokenPlayerType) { replacedPlayerType = newBody.variables.playerType; newBody.variables.playerType = ForceAccessTokenPlayerType; } } if (replacedPlayerType) { console.log(`Replaced '${replacedPlayerType}' player type with '${ForceAccessTokenPlayerType}' player type`); init.body = JSON.stringify(newBody); } } } if (url.includes('edge.ads.twitch.tv')) { const csaiType = url.includes('bp=midroll') ? 'midroll' : url.includes('bp=preroll') ? 'preroll' : 'unknown'; console.log('[AD DEBUG] CSAI ad request detected — type: ' + csaiType + ' (client-side ad insertion, not blockable via m3u8)'); } } return realFetch.apply(this, arguments); }; } // Set up visibility overrides and localStorage hooks to preserve player state across reloads function onContentLoaded() { if (document.getElementById('seventv-extension')) { console.log('[AD DEBUG] Warning: 7TV extension detected — may cause black screen or buffering issues. If you experience problems, try disabling 7TV.'); } // This stops Twitch from pausing the player when in another tab and an ad shows. // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30 try { Object.defineProperty(document, 'visibilityState', { get() { return 'visible'; } }); }catch{} let hidden = document.__lookupGetter__('hidden'); try { Object.defineProperty(document, 'hidden', { get() { return false; } }); }catch{} const block = e => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); }; let wasVideoPlaying = true; const visibilityChange = e => { const videos = document.getElementsByTagName('video'); if (videos.length > 0) { if (hidden && hidden.apply(document) === true) { wasVideoPlaying = !videos[0].paused && !videos[0].ended; } else { if (!playerBufferState.hasStreamStarted) { //console.log('Tab focused. Stream should be active'); playerBufferState.hasStreamStarted = true; } if (wasVideoPlaying && !videos[0].ended && videos[0].paused) { videos[0].play(); } } } block(e); }; document.addEventListener('visibilitychange', visibilityChange, true); try { document.hasFocus = () => true; } catch{} // Hooks for preserving volume / resolution try { const keysToCache = [ 'video-quality', 'video-muted', 'volume', 'lowLatencyModeEnabled',// Low Latency 'persistenceEnabled',// Mini Player ]; const cachedValues = new Map(); for (let i = 0; i < keysToCache.length; i++) { cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i])); } const realSetItem = localStorage.setItem; localStorage.setItem = function(key, value) { if (cachedValues.has(key)) { cachedValues.set(key, value); } realSetItem.apply(this, arguments); }; const realGetItem = localStorage.getItem; localStorage.getItem = function(key) { if (cachedValues.has(key)) { return cachedValues.get(key); } return realGetItem.apply(this, arguments); }; if (!localStorage.getItem.toString().includes(Object.keys({cachedValues})[0])) { // These hooks are useful to preserve player state on player reload // Firefox doesn't allow hooking of localStorage functions but chrome does localStorageHookFailed = true; } } catch (err) { console.log('localStorageHooks failed ' + err) localStorageHookFailed = true; } } declareOptions(window); try { const lsReloadAfterAd = localStorage.getItem('twitchAdSolutions_reloadPlayerAfterAd'); if (lsReloadAfterAd !== null) { ReloadPlayerAfterAd = lsReloadAfterAd === 'true'; } const lsPlayerType = localStorage.getItem('twitchAdSolutions_playerType'); if (lsPlayerType !== null) { ForceAccessTokenPlayerType = lsPlayerType; } const lsPinBackup = localStorage.getItem('twitchAdSolutions_pinBackupPlayerType'); if (lsPinBackup !== null) { PinBackupPlayerType = lsPinBackup === 'true'; } const lsHideAdOverlay = localStorage.getItem('twitchAdSolutions_hideAdOverlay'); if (lsHideAdOverlay === 'true') { const style = document.createElement('style'); style.textContent = '.tas-adblock-overlay { display: none !important; }'; (document.head || document.documentElement).appendChild(style); } } catch {} console.log('[AD DEBUG] Config: ReloadPlayerAfterAd = ' + ReloadPlayerAfterAd + ', ForceAccessTokenPlayerType = ' + ForceAccessTokenPlayerType + ', PinBackupPlayerType = ' + PinBackupPlayerType); hookWindowWorker(); hookFetch(); const realXHROpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url) { if (typeof url === 'string' && url.includes('edge.ads.twitch.tv')) { const csaiType = url.includes('bp=midroll') ? 'midroll' : url.includes('bp=preroll') ? 'preroll' : 'unknown'; console.log('[AD DEBUG] CSAI ad request (XHR) detected — type: ' + csaiType); } return realXHROpen.apply(this, arguments); }; new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType === 1) { const videos = node.tagName === 'VIDEO' ? [node] : node.querySelectorAll ? Array.from(node.querySelectorAll('video, source')) : []; for (const el of videos) { const src = el.src || el.getAttribute('src') || ''; if (src && !src.includes('.ttvnw.net') && !src.includes('blob:') && !src.includes('data:')) { console.log('[AD DEBUG] Non-Twitch video element detected — src: ' + src); } } } } } }).observe(document.documentElement, { childList: true, subtree: true }); if (PlayerBufferingFix) { monitorPlayerBuffering(); } if (document.readyState === "complete" || document.readyState === "interactive") { onContentLoaded(); } else { window.addEventListener("DOMContentLoaded", function() { onContentLoaded(); }); } window.simulateAds = (depth) => { if (depth === undefined || depth < 0) { console.log('Ad depth parameter required (0 = no simulated ad, 1+ = use backup player for given depth)'); return; } postTwitchWorkerMessage('SimulateAds', depth); }; window.allSegmentsAreAdSegments = () => { postTwitchWorkerMessage('AllSegmentsAreAdSegments'); }; })();