twitch-videoad.js text/javascript (function() { if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } // Skip injection in nested frames that aren't legitimate Twitch embed contexts. // Twitch's main channel page has 5+ hidden cross-origin iframes (auth, analytics, // ad SDK, etc.) and uBO injects into all matching ones. Each becomes a racing vaft // instance that fights for player control. Only the top frame hosts the player on // twitch.tv/CHANNEL; nested auxiliary frames are noise. // Allow-list for nested-frame injection: Twitch's three documented embed contexts // (https://dev.twitch.tv/docs/embed/video-and-clips/) — preserves Twitch streams // embedded on third-party sites where vaft runs in an iframe whose parent is on // a different origin. // Use window.frameElement to detect nested frames — null on top frame, the iframe // element on a same-origin nested frame, throws on a cross-origin nested frame. // More reliable than 'window !== window.top' because Tampermonkey wraps window in a // proxy where the strict comparison can return true even on the top frame. let _isNested = false; try { _isNested = window.frameElement !== null; } catch (_e) { _isNested = true; } if (_isNested) { const _host = document.location.hostname; const _isEmbedContext = _host === 'player.twitch.tv' || _host === 'embed.twitch.tv' || document.location.pathname.startsWith('/embed/'); if (!_isEmbedContext) { console.log('[AD DEBUG] vaft-testing skipped — nested frame on ' + _host + document.location.pathname + ' (not a Twitch embed). If you see this on twitch.tv/CHANNEL top frame, please report.'); return; } } // Skip injection on the Twitch clip editor — clips.twitch.tv host or //clip/ path. // Our fetch/Worker hooks and buffer monitor are aimed at the live channel player; on the // clip editor's seekable preview they have no ads to act on and have caused the preview // to freeze when the user drags the trim range. (Sync'd with TTV-AB v6.4.9.) { const _clipHost = document.location.hostname; const _clipPath = document.location.pathname || ''; if (_clipHost === 'clips.twitch.tv' || /^\/[^/]+\/clip\/[^/]+/.test(_clipPath)) { console.log('[AD DEBUG] vaft-testing skipped — clip editor page (' + _clipHost + _clipPath + ').'); return; } } 'use strict'; const ourTwitchAdSolutionsVersion = 615;// Used to prevent conflicts with outdated versions of the scripts console.log('[AD DEBUG] TwitchAdSolutions vaft-testing v' + ourTwitchAdSolutionsVersion + ' loading'); if (typeof window.twitchAdSolutionsVersion !== 'undefined' && window.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { console.log('[AD DEBUG] CONFLICT: vaft-testing v' + ourTwitchAdSolutionsVersion + ' skipped — another script already active (v' + window.twitchAdSolutionsVersion + '). Remove duplicate scripts.'); return; } window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; // Configuration and state shared between window and worker scopes function declareOptions(scope) { // 'twitch-stitched' catches the twitch-stitched-* DATERANGE class family // (-ad, -mid, -pod, etc.) without requiring an exact -ad suffix. Twitch- // prefixed so we don't re-introduce the PR #120 false-positive from bare // 'stitched' substring match. The specific twitch-stitched-ad DATERANGE // marker is a subset of this prefix. scope.AdSignifiers = ['stitched-ad', 'EXT-X-CUE-OUT', 'twitch-stitched', 'EXT-X-DATERANGE:CLASS="twitch-maf-ad"']; scope.AdSegmentURLPatterns = ['/adsquared/', '/_404/', '/processing']; // Precompiled regexes shared across the stripAdSegments hot path. Declared // here (serialized into the worker blob with declareOptions) so literals // inside the per-line strip loop don't recompile on every iteration. scope.TwitchAdUrlRewriteRegex = /(X-TV-TWITCH-AD(?:-[A-Z]+)*-URLS?=")[^"]*(")/g; scope.UriAttributeRegex = /URI="([^"]+)"/; scope.ClientID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; scope.BackupPlayerTypes = [ // Order matters: first clean type wins. 'embed' moved to end — field-observed // Twitch returns GQL 'server error' for streamPlaybackAccessToken on embed when // requested from twitch.tv origin, wasting ~200-400ms per break as first-try. // Kept in case it ever succeeds on some channel/user combo. 'site',//Source 'popout',//Source 'mobile_web',//Mobile 'embed',//Source (unreliable — see note above) // 'autoplay' (360p) removed: when committed as cycle backup, the player gets stuck // in an endless loading circle after the CSAI-only path releases the backup — // autoplay variants don't transition cleanly back to main stream variants. //'picture-by-picture-CACHED'//360p (-CACHED is an internal suffix and is removed) ]; scope.FallbackPlayerType = 'site';// was 'embed' — site is more reliable when all Source types end up ad-laden scope.ForceAccessTokenPlayerType = 'popout'; scope.PreferLowQualityBackup = true;// Hybrid safety net for SSAI-heavy breaks: sticky escape hatch (fires after ~8s stuck in all-stripped state) + autoplay (360p) as last-resort backup when all Source types are ad-laden. Default on; set twitchAdSolutions_preferLowQualityBackup=false to disable. scope.BackupSwapFirst = true;// On ad detect, immediately swap to a backup player-type m3u8 (TTV-AB-style). Avoids MediaSource mixing from strip activity — fewer loading circles in field. Cost: extra fetches on every ad break. Default on; set twitchAdSolutions_backupSwapFirst=false to disable. 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.ReloadCooldownSeconds = 30;// Minimum seconds between reloads — breaks CSAI cascades triggered by reload scope.DisableReloadCap = false;// If true, buffer monitor reloads unlimited times (pre-v47 behavior, risk of cascade) scope.DriftCorrectionRate = 1.1;// Playback rate for catching up to live edge after reload (0 = disable drift correction) scope.EarlyReloadPollThreshold = 3;// Number of consecutive all-stripped polls before triggering early reload (each poll ~2s, so 3 = ~6s, 5 = ~10s, 10 = ~20s; 0 = disable) scope.DisableAdSpoofing = false;// If true, skip the ad-impression spoofing GQL beacons (notifyAdComplete) — for A/B testing whether spoofing affects ad break duration scope.PinBackupPlayerType = 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; } function maskAsNative(fn, name) { fn.toString = () => 'function ' + name + '() { [native code] }'; return fn; } // Creates a new StreamInfo with the full field shape declared up-front. Every // field read or written anywhere in the worker code should be initialized here, // so the complete shape is visible in one place instead of drifting as new // fields get lazily assigned across the codebase. When adding a new streamInfo // field, declare it here first with an appropriate zero value, then assign to // it from the code path that needs it. function createStreamInfo(channelName, encodingsM3u8, usherParams) { return { // Identity ChannelName: channelName, EncodingsM3U8: encodingsM3u8, UsherParams: usherParams, // Resolutions / URL map Urls: [],// xxx.m3u8 -> { Resolution: "284x160", FrameRate: 30.0 } ResolutionList: [], RequestedAds: new Set(), // Modified m3u8 state ModifiedM3U8: null, IsUsingModifiedM3U8: false, // Ad-break state IsShowingAd: false, IsMidroll: false, AdBreakStartedAt: 0, PodLength: 1, CleanPlaylistCount: 0, ConsecutiveZeroStripBreaks: 0, SawCSAIFastPath: false, // Strip state IsStrippingAdSegments: false, NumStrippedAdSegments: 0, RecoverySegments: [], RecoveryStartSeq: undefined,// LOAD-BEARING: explicitly checked with `!== undefined` at the recovery injection site. Must stay undefined, not 0. FreezeStartedAt: 0, ConsecutiveAllStrippedPolls: 0, TotalAllStrippedPolls: 0, LastCleanNativeM3U8: null,// Full-playlist snapshot cached during non-ad polls for all-stripped recovery (mirrors TTV-AB) LastCleanNativePlaylistAt: 0, // Backup player type cycling BackupEncodingsM3U8Cache: [], ActiveBackupPlayerType: null, PinnedBackupPlayerType: null, LastCommittedBackupPlayerType: null, FailedBackupPlayerTypes: new Map(),// Map — failures expire after 15s for retry LoggedBackupAdsByType: null,// lazy-init to Set on first "backup has ads" log CycleRescuedThisBreak: false, LastBackupSwitch: 0, // Early reload EarlyReloadCount: 0, EarlyReloadAtPoll: 0, EarlyReloadTriggered: false, EarlyReloadAwaitingResult: false, EscapeHatchFired: false, // Reload cooldown LastPlayerReload: 0, ReloadTimestamps: [], // Ad segment cache throttling (PR #121) LastAdCachePruneAt: 0, LoggedAdCacheSize1k: false, // Diagnostic flags (once-per-session) HasCheckedUnknownTags: false, HasLoggedAdAttributes: false, HasLoggedUnknownSignifiers: false, }; } const loggedCsaiTypes = new Set(); 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 // One-shot flags for overlay-hide logs. Twitch's React tree re-mounts SDA // wrappers and ad-break cards constantly during an ad break, so the // hide-and-log fires hundreds of times. Log the first occurrence of each // hide type per page load, then stay silent — the hide itself still runs // on every tick via dataset-based dedup. let loggedSdaHide = false; // 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 let injectedBlobUrl = null; let originalRevokeObjectURL = null; function hookWindowWorker() { // Prevent Twitch from revoking our injected worker blob URL if (!URL.revokeObjectURL.__tasMasked) { originalRevokeObjectURL = URL.revokeObjectURL; URL.revokeObjectURL = maskAsNative(function(url) { if (url === injectedBlobUrl) return; return originalRevokeObjectURL.call(this, url); }, 'revokeObjectURL'); URL.revokeObjectURL.__tasMasked = true; } const reinsert = getWorkersForReinsert(window.Worker); const newWorker = class Worker extends (getCleanWorker(window.Worker) || 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; } // Pre-check: verify we can fetch the worker JS before injecting let prefetchedWorkerJs = null; try { prefetchedWorkerJs = getWasmWorkerJs(twitchBlobUrl); } catch {} if (!prefetchedWorkerJs) { super(twitchBlobUrl, options); console.log('[AD DEBUG] Failed to fetch worker JS — falling back to unmodified worker'); return; } console.log('[AD DEBUG] Worker intercepted — injecting ad-block hooks'); const newBlobStr = ` const pendingFetchRequests = new Map(); ${hasAdTags.toString()} ${notifyAdComplete.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()} ${createStreamInfo.toString()} const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}'); declareOptions(self); ReloadPlayerAfterAd = ${ReloadPlayerAfterAd}; ReloadCooldownSeconds = ${ReloadCooldownSeconds}; DisableReloadCap = ${DisableReloadCap}; PinBackupPlayerType = ${PinBackupPlayerType}; EarlyReloadPollThreshold = ${EarlyReloadPollThreshold}; PreferLowQualityBackup = ${PreferLowQualityBackup}; BackupSwapFirst = ${BackupSwapFirst}; DisableAdSpoofing = ${DisableAdSpoofing}; 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. // Response constructor only takes status/statusText/headers — url/redirected/type // must be defined on the instance. IVS WASM validates these (Spade/tracking // requests) and throws NetworkError if they're missing — TTV-AB v6.3.5 fix. const response = new Response(responseData.body, { status: responseData.status, statusText: responseData.statusText, headers: responseData.headers }); try { Object.defineProperty(response, 'url', { value: responseData.url || '', configurable: true }); Object.defineProperty(response, 'redirected', { value: !!responseData.redirected, configurable: true }); Object.defineProperty(response, 'type', { value: responseData.type || 'basic', configurable: true }); } catch {} resolve(response); } } } else if (e.data.key == 'TriggeredPlayerReload') { HasTriggeredPlayerReload = true; } else if (e.data.key == 'ReloadSkipped') { // Main thread refused the reload (player healthy) — clear the // early-reload flags so we can re-fire if the player later stalls let cleared = false; for (const channel in StreamInfos) { const si = StreamInfos[channel]; if (si && si.EarlyReloadTriggered) { si.EarlyReloadTriggered = false; si.EarlyReloadAwaitingResult = false; si.EarlyReloadCount = Math.max(0, (si.EarlyReloadCount || 0) - 1); cleared = true; } } if (cleared) { console.log('[AD DEBUG] Reload skipped by main thread (player healthy) — early reload state cleared, can retry'); } } 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); `; if (injectedBlobUrl && originalRevokeObjectURL) { try { originalRevokeObjectURL.call(URL, injectedBlobUrl); } catch {} } injectedBlobUrl = URL.createObjectURL(new Blob([newBlobStr])); super(injectedBlobUrl, options); twitchWorkers.length = 0; twitchWorkers.push(this); this.addEventListener('message', (e) => { if (e.data.key == 'UpdateAdBlockBanner') { updateAdblockBanner(e.data); // Track backup stream switches (start and end of ad break) if (e.data.hasAds !== !!playerBufferState.inAdBreak) { playerBufferState.lastBackupSwitchAt = Date.now(); // Reset position tracking on ad-end so the stream switch gap isn't detected as a jump if (!e.data.hasAds) { playerBufferState.position = 0; } } playerBufferState.inAdBreak = !!e.data.hasAds; // Clear drift catch-up when ads start — don't run 1.1x during ad handling if (e.data.hasAds && (driftCatchUpInterval || driftCatchUpTimeout)) { if (driftCatchUpInterval) { clearInterval(driftCatchUpInterval); driftCatchUpInterval = null; } if (driftCatchUpTimeout) { clearTimeout(driftCatchUpTimeout); driftCatchUpTimeout = null; } try { document.querySelector('video').playbackRate = 1.0; } catch {} } } else if (e.data.key == 'PauseResumePlayer') { doTwitchPlayerTask(true, false); } else if (e.data.key == 'ReloadPlayer') { doTwitchPlayerTask(false, true, e.data.kind); } }); 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 }); } }); // Worker crash recovery — IVS WASM worker can fire RuntimeError // (e.g. "index out of bounds") and die. A single crash fires multiple // error events; dedupe via a local flag. On first error, trigger a // hard reload via the main reload path — Twitch re-spawns the worker // as part of the new player instance, and existing reload cooldown // prevents runaway restart loops. let crashed = false; this.addEventListener('error', (e) => { if (crashed) return; crashed = true; console.log('[AD DEBUG] IVS WASM worker crashed: ' + ((e && e.message) || 'unknown error') + ' — triggering hard reload to recover'); try { doTwitchPlayerTask(false, true, 'early'); } catch (err) { console.log('[AD DEBUG] Worker crash recovery failed: ' + err.message); } }); } }; 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 = createStreamInfo(channelName, encodingsM3u8, parsedUrl.search); const lines = encodingsM3u8.split(/\r?\n/); for (let i = 0; i < lines.length - 1; i++) { // Accept v2 API variant URLs (raw CDN URLs without '.m3u8'). const nextLine = lines[i + 1]?.trim(); if (lines[i].startsWith('#EXT-X-STREAM-INF') && nextLine && !nextLine.startsWith('#') && (nextLine.includes('.m3u8') || nextLine.includes('://'))) { 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'); } } } // Note: do NOT set streamInfo.LastPlayerReload here. It was previously // set unconditionally on new stream session creation, which caused the // first end-of-break reload of every new channel to be blocked by // cooldown — the cooldown check treated the session-creation timestamp // as a recent reload, even though no reload had actually occurred. 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)); } // Spoof ad completion events to Twitch's backend to clear the ad state function notifyAdComplete(textStr) { try { const matches = textStr.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); if (!matches || matches.length <= 1) { if (!notifyAdComplete.loggedNoMatch) { notifyAdComplete.loggedNoMatch = true; const dateRangeLine = textStr.match(/#EXT-X-DATERANGE:[^\n]{0,200}/); console.log('[AD DEBUG] notifyAdComplete: no stitched-ad DATERANGE match. Sample DATERANGE: ' + (dateRangeLine ? dateRangeLine[0] : 'none found')); } return; } const attr = parseAttributes(matches[1]); const radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; if (!radToken) { if (!notifyAdComplete.loggedNoToken) { notifyAdComplete.loggedNoToken = true; console.log('[AD DEBUG] notifyAdComplete: matched DATERANGE but no RADS token. Attributes: ' + Object.keys(attr).join(', ')); } return; } const baseData = { stitched: true, ad_id: attr['X-TV-TWITCH-AD-ADVERTISER-ID'] || '', roll_type: (attr['X-TV-TWITCH-AD-ROLL-TYPE'] || '').toLowerCase(), creative_id: attr['X-TV-TWITCH-AD-CREATIVE-ID'] || '', order_id: attr['X-TV-TWITCH-AD-ORDER-ID'] || '', line_item_id: attr['X-TV-TWITCH-AD-LINE-ITEM-ID'] || '', player_mute: true, player_volume: 0.0, visible: false, duration: 0 }; const podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] || '1', 10); for (let podPosition = 0; podPosition < podLength; podPosition++) { const payload = { ...baseData, ad_position: podPosition, total_ads: podLength }; const makePacket = (event, extra) => [{ operationName: 'ClientSideAdEventHandling_RecordAdEvent', variables: { input: { eventName: event, eventPayload: JSON.stringify({ ...payload, ...extra }), radToken } }, extensions: { persistedQuery: { version: 1, sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b' } } }]; gqlRequest(makePacket('video_ad_impression')).catch(() => {}); for (let q = 1; q <= 4; q++) { gqlRequest(makePacket('video_ad_quartile_complete', { quartile: q })).catch(() => {}); } gqlRequest(makePacket('video_ad_pod_complete')).catch(() => {}); } console.log('[AD DEBUG] Spoofed ad completion for ' + podLength + ' ad(s) — roll: ' + baseData.roll_type); } catch (err) { console.log('[AD DEBUG] Ad completion spoof failed: ' + err.message); } } 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'; // Log ad tracking attribute names once per stream (helps identify new beacons) if (!streamInfo.HasLoggedAdAttributes) { const adAttrs = textStr.match(/X-TV-TWITCH-AD[A-Z-]*(?==")/g); if (adAttrs && adAttrs.length > 0) { streamInfo.HasLoggedAdAttributes = true; console.log('[AD DEBUG] Ad tracking attributes seen: ' + [...new Set(adAttrs)].join(', ')); } } // Log potential ad markers that aren't in AdSignifiers (candidates for future inclusion) if (!streamInfo.HasLoggedUnknownSignifiers) { const candidates = new Set(); let sm; const classRe = /EXT-X-DATERANGE:[^\n]*CLASS="(twitch-[^"]+)"/g; while ((sm = classRe.exec(textStr)) !== null) { candidates.add('EXT-X-DATERANGE:CLASS="' + sm[1] + '"'); } const tagRe = /(SCTE35-[A-Z-]+|EXT-X-CUE-[A-Z-]+)/g; while ((sm = tagRe.exec(textStr)) !== null) { candidates.add(sm[1]); } // Substring check (not exact): a candidate is "known" if any AdSignifier // appears within it. This handles prefix signifiers like 'twitch-stitched' // covering 'EXT-X-DATERANGE:CLASS="twitch-stitched-ad"' etc. const unknown = [...candidates].filter(c => !AdSignifiers.some(s => c.includes(s))); if (unknown.length > 0) { streamInfo.HasLoggedUnknownSignifiers = true; console.log('[AD DEBUG] Potential ad markers seen but not in AdSignifiers: ' + unknown.join(', ') + ' (candidates for future inclusion)'); } } 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(TwitchAdUrlRewriteRegex, `$1${newAdUrl}$2`); const isLiveSegment = line.includes(',live'); if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!isLiveSegment || 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') && isLiveSegment) { liveSegments.push({ extinf: line, url: lines[i + 1] }); } else if (line.startsWith('#EXT-X-PART:')) { // LL-HLS part: URI is inline as an attribute. Strip if it matches a known // ad URL (already in cache from a parallel EXTINF strip, or matches a URL pattern). // Without this, the player may use the parts path to fetch ad media via low-latency. const partUriMatch = line.match(UriAttributeRegex); const partUri = partUriMatch ? partUriMatch[1] : ''; if (partUri && (AdSegmentCache.has(partUri) || AdSegmentURLPatterns.some((p) => partUri.includes(p)))) { AdSegmentCache.set(partUri, Date.now()); lines[i] = ''; hasStrippedAdSegments = true; } } else if (line.startsWith('#EXT-X-TWITCH-PREFETCH:') || line.startsWith('#EXT-X-PRELOAD-HINT:')) { // LL-HLS prefetch/preload hints can point at upcoming ad segments before any // EXTINF line or ad signifier has materialized in the playlist. Detect here so // hasStrippedAdSegments flips on the first poll and the post-loop unconditional // prefetch strip fires. Ported from TTV-AB 52b41b4. let hintUrl = ''; if (line.startsWith('#EXT-X-TWITCH-PREFETCH:')) { hintUrl = line.substring('#EXT-X-TWITCH-PREFETCH:'.length).trim(); } else { const hintMatch = line.match(UriAttributeRegex); hintUrl = hintMatch ? hintMatch[1] : ''; } if (hintUrl && (AdSegmentCache.has(hintUrl) || AdSegmentURLPatterns.some((p) => hintUrl.includes(p)))) { AdSegmentCache.set(hintUrl, Date.now()); hasStrippedAdSegments = true; } } } // Moved out of the per-line loop: a per-line scan for any signifier is // semantically equivalent to a single full-text scan, since the check has // no line-level state — it just flips hasStrippedAdSegments = true on first // match. One scan instead of N_lines * N_signifiers scans (~100x fewer // includes() calls on a typical 100-line m3u8). if (!hasStrippedAdSegments && AdSignifiers.some((s) => textStr.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].startsWith('#EXT-X-PRELOAD-HINT:')) { lines[i] = ''; } } } else { streamInfo.NumStrippedAdSegments = 0; } // Cache live segments for recovery (plus the MEDIA-SEQUENCE of the oldest cached segment, // so the player accepts injected recovery segments as the correct position in the stream) if (liveSegments.length > 0) { streamInfo.RecoverySegments = liveSegments.slice(-6); const seq = parseInt((textStr.match(/#EXT-X-MEDIA-SEQUENCE:(\d+)/) || [])[1]); if (!isNaN(seq)) { streamInfo.RecoveryStartSeq = seq + Math.max(0, liveSegments.length - streamInfo.RecoverySegments.length); } } // If all segments were stripped, try to prevent black screen via recovery content. // Prefer the full-playlist snapshot from a recent non-ad poll (mirrors TTV-AB // LastCleanNativeM3U8 approach) — gives the player 4-6 live segments worth of // content vs the thin per-segment recovery cache. Falls back to the per-segment // cache if the snapshot is stale or missing. if (hasStrippedAdSegments && liveSegments.length === 0) { streamInfo.ConsecutiveAllStrippedPolls = (streamInfo.ConsecutiveAllStrippedPolls || 0) + 1; streamInfo.TotalAllStrippedPolls = (streamInfo.TotalAllStrippedPolls || 0) + 1; if (!streamInfo.FreezeStartedAt) streamInfo.FreezeStartedAt = Date.now(); // Primary: fresh full-playlist snapshot (< 1.5s old, must not itself contain ad markers) const snapshotAge = streamInfo.LastCleanNativePlaylistAt ? (Date.now() - streamInfo.LastCleanNativePlaylistAt) : Infinity; if (streamInfo.LastCleanNativeM3U8 && snapshotAge <= 1500 && !hasAdTags(streamInfo.LastCleanNativeM3U8)) { console.log('[AD DEBUG] All segments stripped — reusing last clean native playlist (' + snapshotAge + 'ms old)'); streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; return streamInfo.LastCleanNativeM3U8; } // Fallback: per-segment recovery cache (existing behavior) if (streamInfo.RecoverySegments && streamInfo.RecoverySegments.length > 0) { console.log('[AD DEBUG] All segments stripped — restoring ' + streamInfo.RecoverySegments.length + ' recovery segments'); if (streamInfo.RecoveryStartSeq !== undefined) { for (let j = 0; j < lines.length; j++) { if (lines[j].startsWith('#EXT-X-MEDIA-SEQUENCE:')) { lines[j] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.RecoveryStartSeq; break; } } } for (let j = 0; j < streamInfo.RecoverySegments.length; j++) { lines.push(streamInfo.RecoverySegments[j].extinf); lines.push(streamInfo.RecoverySegments[j].url); } } } else if (liveSegments.length > 0) { // Reset freeze counter when live segments are available streamInfo.ConsecutiveAllStrippedPolls = 0; } streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; const now = Date.now(); // Throttle cache prune to once per 60s. The 120s TTL gives plenty of headroom // and scanning the full cache on every m3u8 poll adds up during heavy ad break // sequences (LL-HLS can poll multiple times per second). Ported from TTV-AB. if (!streamInfo.LastAdCachePruneAt || now - streamInfo.LastAdCachePruneAt > 60000) { streamInfo.LastAdCachePruneAt = now; AdSegmentCache.forEach((value, key, map) => { if (value < now - 120000) { map.delete(key); } }); // Diagnostic: log once per streamInfo when the cache crosses 1000 entries, // so cache bloat from a future TTL/prune bug would surface in user reports // instead of staying invisible. if (AdSegmentCache.size > 1000 && !streamInfo.LoggedAdCacheSize1k) { streamInfo.LoggedAdCacheSize1k = true; console.log('[AD DEBUG] AdSegmentCache crossed 1000 entries (now ' + AdSegmentCache.size + ') — possible cache bloat'); } } 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++) { // Accept v2 API variant URLs (raw CDN URLs without '.m3u8'). const nextLine = encodingsLines[i + 1]?.trim(); if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && nextLine && !nextLine.startsWith('#') && (nextLine.includes('.m3u8') || nextLine.includes('://'))) { 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; // Cache the clean main stream m3u8 for all-stripped recovery fallback. // Mirrors TTV-AB's LastCleanNativeM3U8 mechanism — full-playlist snapshot // captured during non-ad polls, reused by stripAdSegments when the break // leaves the main playlist entirely stripped. if (!haveAdTags && !streamInfo.IsShowingAd && textStr.indexOf('#EXTINF') !== -1) { streamInfo.LastCleanNativeM3U8 = textStr; streamInfo.LastCleanNativePlaylistAt = Date.now(); } if (haveAdTags) { streamInfo.CleanPlaylistCount = 0; streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); if (!streamInfo.IsShowingAd) { streamInfo.IsShowingAd = true; streamInfo.AdBreakStartedAt = Date.now(); const podLengthMatch = textStr.match(/X-TV-TWITCH-AD-POD-LENGTH="(\d+)"/); const podLength = podLengthMatch ? parseInt(podLengthMatch[1], 10) : 1; // Reset early-reload state for new ad break; allow up to one early reload per ad in pod streamInfo.PodLength = podLength; streamInfo.EarlyReloadTriggered = false; streamInfo.EarlyReloadCount = 0; streamInfo.EarlyReloadAtPoll = 0; streamInfo.SawCSAIFastPath = false; streamInfo.LastCommittedBackupPlayerType = null; streamInfo.FreezeStartedAt = 0; streamInfo.CycleRescuedThisBreak = false; console.log('[AD DEBUG] Ad detected — type: ' + (streamInfo.IsMidroll ? 'midroll' : 'preroll') + ', channel: ' + streamInfo.ChannelName + ', pod: ' + podLength + ' ad(s) (~' + (podLength * 30) + 's expected), signifiers: ' + getMatchedAdSignifiers(textStr).join(', ')); if (!DisableAdSpoofing) { notifyAdComplete(textStr); } 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' }); } // Sticky CSAI fast path: if a prior poll in THIS break already confirmed the break // is CSAI-only, stay on the fast path even if Twitch starts serving older buffered // segments that flip hasNonLiveSegment to true. Prevents a slow backup search from // running on poll 2+, completing tens of seconds after the break ended, and // overwriting cleared streamInfo state with stale backup data (PR #124 from release). // Sticky CSAI escape hatch (PreferLowQualityBackup): after ~8s stuck, fall through to backup search. if (PreferLowQualityBackup && streamInfo.SawCSAIFastPath && (streamInfo.ConsecutiveAllStrippedPolls || 0) >= 4) { const stuckPolls = streamInfo.ConsecutiveAllStrippedPolls; const recoveryCacheSize = streamInfo.RecoverySegments?.length || 0; const earlyReloadInfo = (streamInfo.EarlyReloadCount || 0) + '/' + Math.max(1, streamInfo.PodLength || 1); console.log('[AD DEBUG] Sticky CSAI escape hatch — stuck ' + stuckPolls + ' polls (~' + (stuckPolls * 2) + 's), EarlyReloadCount=' + earlyReloadInfo + ', recovery cache=' + recoveryCacheSize + ' segments, falling through to backup search'); streamInfo.SawCSAIFastPath = false; streamInfo.EscapeHatchFired = true; } if (streamInfo.SawCSAIFastPath && !streamInfo.IsUsingModifiedM3U8) { // Stay on the sticky fast path for the whole break. No mid-break clear. // stripAdSegments still handles any real EXTINF ad segments via the segment // cache (cached URLs get BLANK_MP4 from the fetch hook), so ads are blocked // even without backup switching. Skipping backup search for the whole CSAI // break saves ~20 wasted fetches — the backup wouldn't help anyway since // every player type has the same CSAI ads. if (IsAdStrippingEnabled) { textStr = stripAdSegments(textStr, false, streamInfo); } // Early reload during prolonged freeze — mirrors the check in the normal // backup-search path which we'd otherwise skip entirely by returning early // from the sticky path. Without this, heavy SSAI breaks on CSAI-confirmed // streams leave the player replaying the thin recovery cache for the full // break duration. Bounded to maxEarlyReloads per ad in pod. if (streamInfo.EarlyReloadAwaitingResult) { streamInfo.EarlyReloadAwaitingResult = false; console.log('[AD DEBUG] Early reload result (sticky path): still ads — continuing recovery loop'); streamInfo.EarlyReloadTriggered = false; } const stickyRecoveryThin = (streamInfo.RecoverySegments?.length || 0) < 3; const stickyMaxEarlyReloads = stickyRecoveryThin ? Math.max(2, streamInfo.PodLength || 1) : Math.max(1, streamInfo.PodLength || 1); const stickyEffectiveThreshold = stickyRecoveryThin ? 1 : EarlyReloadPollThreshold; if (EarlyReloadPollThreshold > 0 && (streamInfo.ConsecutiveAllStrippedPolls || 0) >= stickyEffectiveThreshold && !streamInfo.EarlyReloadTriggered && (streamInfo.EarlyReloadCount || 0) < stickyMaxEarlyReloads) { streamInfo.EarlyReloadTriggered = true; streamInfo.EarlyReloadAwaitingResult = true; streamInfo.EarlyReloadCount = (streamInfo.EarlyReloadCount || 0) + 1; streamInfo.EarlyReloadAtPoll = streamInfo.TotalAllStrippedPolls || streamInfo.ConsecutiveAllStrippedPolls; const stickyReason = stickyRecoveryThin ? ' (thin recovery cache: ' + (streamInfo.RecoverySegments?.length || 0) + ' segments)' : ''; console.log('[AD DEBUG] Early reload triggered (sticky path) — ' + streamInfo.ConsecutiveAllStrippedPolls + ' consecutive all-stripped polls' + stickyReason + ' [' + streamInfo.EarlyReloadCount + '/' + stickyMaxEarlyReloads + ']'); postMessage({ key: 'ReloadPlayer', kind: 'early' }); } postMessage({ key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, isStrippingAdSegments: streamInfo.IsStrippingAdSegments, numStrippedAdSegments: streamInfo.NumStrippedAdSegments, activeBackupPlayerType: null }); return textStr; } // CSAI fast path: if all segments in the main stream are live, skip backup search. // CSAI ads are delivered outside the m3u8 — the main stream segments are clean. // Just strip tracking URLs and return the main stream directly, avoiding the // backup stream switch that causes a 20-40s rebuffer gap. const mainStreamLines = textStr.split(/\r?\n/); let hasNonLiveSegment = false; for (let i = 0; i < mainStreamLines.length; i++) { if (mainStreamLines[i].startsWith('#EXTINF') && !mainStreamLines[i].includes(',live')) { hasNonLiveSegment = true; break; } } // BackupSwapFirst (opt-in): skip sticky CSAI path entirely, always fall through to // backup search on ad detect. Mimics TTV-AB's backup-swap-first flow — avoids // MediaSource mixing from strip activity (no BLANK_MP4 injection, no recovery // segment replay), which users report produces fewer loading circles. Cost: extra // fetches on every ad break (token requests for each backup type tried). if (!hasNonLiveSegment && !streamInfo.IsUsingModifiedM3U8 && !BackupSwapFirst) { streamInfo.SawCSAIFastPath = true; console.log('[AD DEBUG] CSAI fast path — all segments live, skipping backup search'); if (IsAdStrippingEnabled) { textStr = stripAdSegments(textStr, false, streamInfo); } postMessage({ key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, isStrippingAdSegments: streamInfo.IsStrippingAdSegments, numStrippedAdSegments: streamInfo.NumStrippedAdSegments, activeBackupPlayerType: null }); return textStr; } 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 = PreferLowQualityBackup ? [...BackupPlayerTypes, 'autoplay'] : [...BackupPlayerTypes]; if (streamInfo.PinnedBackupPlayerType) { const pinnedIndex = playerTypesToTry.indexOf(streamInfo.PinnedBackupPlayerType); if (pinnedIndex > 0) { playerTypesToTry.splice(pinnedIndex, 1); playerTypesToTry.unshift(streamInfo.PinnedBackupPlayerType); } } // Real-time contamination reorder: on poll 2+ of a break, move types that were // already logged as ad-laden earlier in the same break to the end of iteration. // Lets untried/clean types (typically autoplay on SSAI-heavy channels like warn) // get tried first instead of re-checking types we already know are contaminated. // LoggedBackupAdsByType is populated below at the "also has ads" log site and // cleared at end-of-break, so this is per-break adaptive. if (streamInfo.LoggedBackupAdsByType && streamInfo.LoggedBackupAdsByType.size > 0) { const clean = []; const contam = []; for (const t of playerTypesToTry) { if (streamInfo.LoggedBackupAdsByType.has(t)) contam.push(t); else clean.push(t); } if (contam.length > 0 && clean.length > 0) { playerTypesToTry.length = 0; playerTypesToTry.push(...clean, ...contam); if (!streamInfo.LoggedContamReorderThisBreak) { streamInfo.LoggedContamReorderThisBreak = true; console.log('[AD DEBUG] Contamination-aware reorder — trying [' + clean.join(', ') + '] before known-contaminated [' + contam.join(', ') + ']'); } } } for (let playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < playerTypesToTry.length; playerTypeIndex++) { const playerType = playerTypesToTry[playerTypeIndex]; const realPlayerType = playerType.replace('-CACHED', ''); const failedAt = streamInfo.FailedBackupPlayerTypes.get(realPlayerType); if (failedAt && (Date.now() - failedAt) < 15000) { 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(); // Twitch returns streamPlaybackAccessToken in two observed shapes: // { data: { streamPlaybackAccessToken: {...} } } (most player types) // { streamPlaybackAccessToken: {...} } (flatter, observed for 'embed') // Accept either. Field-observed silently dropping embed backup otherwise. const spat = accessToken?.data?.streamPlaybackAccessToken || accessToken?.streamPlaybackAccessToken; if (!spat) { const errInfo = accessToken?.errors ? ' errors: ' + JSON.stringify(accessToken.errors).substring(0, 300) : ''; console.log('[AD DEBUG] GQL response missing streamPlaybackAccessToken for ' + realPlayerType + '. Response keys: ' + JSON.stringify(Object.keys(accessToken || {})) + errInfo); // Lockout for 15s so we don't re-hit this known-broken type every poll. // Parity with the HTTP-error and exception paths below — this GQL-error path // was the only failure mode that didn't mark FailedBackupPlayerTypes, causing // ~34 identical retries per break on channels where embed/autoplay GQL // consistently returns "server error" (field-observed on warn). streamInfo.FailedBackupPlayerTypes.set(realPlayerType, Date.now()); continue; } const urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams); urlInfo.searchParams.set('sig', spat.signature); urlInfo.searchParams.set('token', spat.value); const encodingsM3u8Response = await realFetch(urlInfo.href); if (encodingsM3u8Response.status === 200) { encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType] = await encodingsM3u8Response.text(); } else { console.log('[AD DEBUG] Usher HTTP ' + encodingsM3u8Response.status + ' for ' + realPlayerType); } } else { let errorBody = ''; try { errorBody = ' — ' + (await accessTokenResponse.text()).substring(0, 200); } catch {} console.log('[AD DEBUG] Access token HTTP ' + accessTokenResponse.status + ' for ' + realPlayerType + (accessTokenResponse.status === 403 ? ' (integrity: ' + (ClientIntegrityHeader ? 'present' : 'missing') + ')' : '') + errorBody); streamInfo.FailedBackupPlayerTypes.set(realPlayerType, Date.now()); } } catch (err) { console.log('[AD DEBUG] Access token failed for ' + realPlayerType + ': ' + err.message); streamInfo.FailedBackupPlayerTypes.set(realPlayerType, Date.now()); } } 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)) { if ((streamInfo.ConsecutiveAllStrippedPolls || 0) >= 2 && !hasAdTags(m3u8Text)) { const prevType = streamInfo.LastCommittedBackupPlayerType; if (prevType && prevType !== playerType) { console.log('[AD DEBUG] Cycle switched to different clean type (' + playerType + ', was ' + prevType + ') during freeze — recovered without reload'); // Only mark as cycle-rescued when we ACTUALLY switched player types. // Natural recovery (same type became clean) still needs the end-of-break // reload to refresh the player buffer — skipping it leaves the player // stuck with low buffer and the buffer monitor unable to recover. streamInfo.CycleRescuedThisBreak = true; } else { console.log('[AD DEBUG] Same backup type (' + playerType + ') became clean during freeze — natural recovery'); } } backupPlayerType = playerType; backupM3u8 = m3u8Text; break; } if (hasAdTags(m3u8Text)) { if (!streamInfo.LoggedBackupAdsByType) streamInfo.LoggedBackupAdsByType = new Set(); if (!streamInfo.LoggedBackupAdsByType.has(playerType)) { streamInfo.LoggedBackupAdsByType.add(playerType); console.log('[AD DEBUG] Backup stream (' + playerType + ') also has ads'); } } if (isFullyCachedPlayerType) { break; } // Keep iterating other player types in case one is clean. // Take ad-laden backup as last resort: minimal-requests window, // 3+ ad breaks with 0 strips (false positive), or final type tried. if (isDoingMinimalRequests || streamInfo.ConsecutiveZeroStripBreaks >= 3 || playerTypeIndex >= playerTypesToTry.length - 1) { if (playerTypeIndex >= playerTypesToTry.length - 1 && !isDoingMinimalRequests && streamInfo.ConsecutiveZeroStripBreaks < 3) { console.log('[AD DEBUG] All backup player types ad-laden — taking ' + playerType + ' as last-resort fallback (strip+recovery path will engage)'); } 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) { // Don't fall back to a type we've already marked contaminated this break. // Without this guard, when all Source types go ad-laden mid-break the iteration // ends with no clean commit AND fallbackM3u8 still pointing at FallbackPlayerType's // ad-laden m3u8 — we'd silently re-commit the same contaminated site on every poll // (no "Blocking ads" log because ActiveBackupPlayerType unchanged), and the user // sees the full ad pod with no indication of failure. Better to leave backupM3u8 // null and let the "No ad-free backup stream found" log fire so it's visible. if (streamInfo.LoggedBackupAdsByType && streamInfo.LoggedBackupAdsByType.has(FallbackPlayerType)) { console.log('[AD DEBUG] Skipping fallback to ' + FallbackPlayerType + ' — marked contaminated this break (' + [...streamInfo.LoggedBackupAdsByType].join(', ') + ' all ad-laden)'); } else { backupPlayerType = FallbackPlayerType; backupM3u8 = fallbackM3u8; } } // Stale-commit guard: multiple processM3U8 calls can be in flight concurrently for // the same streamInfo. If this backup search started during the ad break but // completed AFTER a later poll already ran the end-of-break reset, committing the // backup here would overwrite cleared state and feed stale playlist data to the // player (PR #124 from release). Check IsShowingAd to discard stale results. if (backupM3u8 && streamInfo.IsShowingAd) { textStr = backupM3u8; streamInfo.LastCommittedBackupPlayerType = backupPlayerType; if (streamInfo.ActiveBackupPlayerType != backupPlayerType) { streamInfo.ActiveBackupPlayerType = backupPlayerType; // Auto-pin source-quality backup types (embed, site, popout) to skip failed types on next break. // Never auto-pin low-quality types (autoplay, mobile_web) to avoid quality lock. const sourceQualityTypes = ['embed', 'site', 'popout']; if (PinBackupPlayerType || sourceQualityTypes.includes(backupPlayerType)) { streamInfo.PinnedBackupPlayerType = backupPlayerType; } console.log(`Blocking${(streamInfo.IsMidroll ? ' midroll ' : ' ')}ads (${backupPlayerType}) — backup found in ${Date.now() - backupSearchStart}ms`); if (streamInfo.EscapeHatchFired) { const qualityTier = backupPlayerType === 'autoplay' ? '360p' : 'Source'; console.log('[AD DEBUG] Post-escape backup: ' + backupPlayerType + ' (' + qualityTier + ') — recovered from sticky-path freeze'); } else if (backupPlayerType === 'autoplay' && PreferLowQualityBackup) { const sourceTried = streamInfo.LoggedBackupAdsByType?.size || 0; if (sourceTried === 0) { console.log('[AD DEBUG] Autoplay backup committed — 360p pinned from prior break (PreferLowQualityBackup)'); } else { console.log('[AD DEBUG] Autoplay backup committed — 360p fallback after ' + sourceTried + ' Source type(s) ad-laden (PreferLowQualityBackup)'); } } streamInfo.LastBackupSwitch = Date.now(); } } else if (backupM3u8 && !streamInfo.IsShowingAd) { console.log('[AD DEBUG] Discarded stale backup commit (' + backupPlayerType + ', ' + (Date.now() - backupSearchStart) + 'ms) — break ended during search'); } 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'); } // Log reload outcome on the poll after early reload triggered if (streamInfo.EarlyReloadAwaitingResult) { streamInfo.EarlyReloadAwaitingResult = false; if (textStr.includes(',live') && streamInfo.IsStrippingAdSegments) { console.log('[AD DEBUG] Early reload result: partial — some live segments returned'); } else if (!streamInfo.IsStrippingAdSegments) { console.log('[AD DEBUG] Early reload result: clean — freeze ended'); // Reset trigger flag so subsequent freezes within the same pod can re-fire (bounded by EarlyReloadCount/PodLength) streamInfo.EarlyReloadTriggered = false; } else { console.log('[AD DEBUG] Early reload result: still ads — continuing recovery loop'); streamInfo.EarlyReloadTriggered = false; } } // Early reload during prolonged freeze: if we've been looping recovery segments // for N+ polls, trigger a reload to attempt fresh content. Bounded to one reload // per ad in the pod (e.g. 2-ad pod = up to 2 early reloads). const recoveryThin = (streamInfo.RecoverySegments?.length || 0) < 3; const maxEarlyReloads = recoveryThin ? Math.max(2, streamInfo.PodLength || 1) : Math.max(1, streamInfo.PodLength || 1); // Thin recovery cache fast path: when fewer than 3 live segments are cached // for fallback, the player will burn through them in seconds. Trigger early // reload immediately on the first all-stripped poll instead of waiting for // the normal threshold — the ~1-2s reload disruption is much shorter than // the strip+buffer-rebuild stall that would otherwise occur (~10-15s). const effectiveThreshold = recoveryThin ? 1 : EarlyReloadPollThreshold; if (EarlyReloadPollThreshold > 0 && (streamInfo.ConsecutiveAllStrippedPolls || 0) >= effectiveThreshold && !streamInfo.EarlyReloadTriggered && (streamInfo.EarlyReloadCount || 0) < maxEarlyReloads) { streamInfo.EarlyReloadTriggered = true; streamInfo.EarlyReloadAwaitingResult = true; streamInfo.EarlyReloadCount = (streamInfo.EarlyReloadCount || 0) + 1; streamInfo.EarlyReloadAtPoll = streamInfo.TotalAllStrippedPolls || streamInfo.ConsecutiveAllStrippedPolls; const reason = recoveryThin ? ' (thin recovery cache: ' + (streamInfo.RecoverySegments?.length || 0) + ' segments)' : ''; console.log('[AD DEBUG] Early reload triggered — ' + streamInfo.ConsecutiveAllStrippedPolls + ' consecutive all-stripped polls' + reason + ' [' + streamInfo.EarlyReloadCount + '/' + maxEarlyReloads + ']'); postMessage({ key: 'ReloadPlayer', kind: 'early' }); } } else if (streamInfo.IsShowingAd) { streamInfo.CleanPlaylistCount++; // Check if the current playlist has live segments — if not, backup stream is dead const hasLiveSegments = textStr.includes(',live'); if (streamInfo.CleanPlaylistCount >= 2 || !hasLiveSegments) { if (!hasLiveSegments) { console.log('[AD DEBUG] Backup stream has no live segments — forcing immediate reload'); } const adBreakDurationSec = streamInfo.AdBreakStartedAt ? ((Date.now() - streamInfo.AdBreakStartedAt) / 1000).toFixed(1) : '?'; console.log('Finished blocking ads — stripped ' + streamInfo.NumStrippedAdSegments + ' ad segments, duration: ' + adBreakDurationSec + 's (IsUsingModifiedM3U8: ' + streamInfo.IsUsingModifiedM3U8 + ')'); if (streamInfo.TotalAllStrippedPolls > 0) { const reloadInfo = streamInfo.EarlyReloadAtPoll ? ', early reload at poll ' + streamInfo.EarlyReloadAtPoll : ''; const wallClockFreeze = streamInfo.FreezeStartedAt ? ((Date.now() - streamInfo.FreezeStartedAt) / 1000).toFixed(1) + 's wall-clock' : 'unknown'; console.log('[AD DEBUG] Ad break stats: ' + streamInfo.TotalAllStrippedPolls + ' all-stripped polls, freeze duration: ' + wallClockFreeze + reloadInfo); } const hadStrippedSegments = streamInfo.NumStrippedAdSegments > 0; // Only count toward false-positive guard if it was NOT a CSAI-only break // and not a backup-swap-first break. CSAI breaks are real ads delivered // outside the m3u8 — they legitimately produce 0 stripped segments. // BackupSwapFirst swaps to backup on every ad detect, so 0 stripped is // expected by design (we never strip native; we just serve backup). if (!hadStrippedSegments && !streamInfo.SawCSAIFastPath && !BackupSwapFirst) { if (!streamInfo.ConsecutiveZeroStripBreaks) streamInfo.ConsecutiveZeroStripBreaks = 0; streamInfo.ConsecutiveZeroStripBreaks++; if (streamInfo.ConsecutiveZeroStripBreaks >= 3) { console.log('[AD DEBUG] Warning: ' + streamInfo.ConsecutiveZeroStripBreaks + ' consecutive non-CSAI ad breaks with 0 segments stripped — possible false positive from ad signifiers'); } } else if (hadStrippedSegments || streamInfo.SawCSAIFastPath || BackupSwapFirst) { // Reset is symmetric with the increment guard above — any positive "break // was handled cleanly" signal resets the false-positive history. Prevents // stale suspicious history from bleeding across legitimately-handled breaks. streamInfo.ConsecutiveZeroStripBreaks = 0; } streamInfo.IsShowingAd = false; streamInfo.IsStrippingAdSegments = false; streamInfo.NumStrippedAdSegments = 0; streamInfo.ActiveBackupPlayerType = null; streamInfo.RequestedAds.clear(); streamInfo.FailedBackupPlayerTypes.clear(); // Clear pin if the pinned type was contaminated this break — avoids wasted // first-try fetch on next break. Field-observed on pge4: PinnedBackupPlayerType // gets set to the last-committed type via PinBackupPlayerType=true, but when // that type went contaminated mid-break we'd still try it first next break. if (streamInfo.PinnedBackupPlayerType && streamInfo.LoggedBackupAdsByType && streamInfo.LoggedBackupAdsByType.has(streamInfo.PinnedBackupPlayerType)) { console.log('[AD DEBUG] Clearing pinned backup type ' + streamInfo.PinnedBackupPlayerType + ' — contaminated this break, would waste first-try next break'); streamInfo.PinnedBackupPlayerType = null; } if (streamInfo.LoggedBackupAdsByType) streamInfo.LoggedBackupAdsByType.clear(); streamInfo.LoggedContamReorderThisBreak = false; streamInfo.CleanPlaylistCount = 0; streamInfo.ConsecutiveAllStrippedPolls = 0; streamInfo.EarlyReloadTriggered = false; streamInfo.EarlyReloadAwaitingResult = false; streamInfo.EarlyReloadAtPoll = 0; streamInfo.TotalAllStrippedPolls = 0; streamInfo.HasLoggedAdAttributes = false; streamInfo.HasLoggedUnknownSignifiers = false; streamInfo.SawCSAIFastPath = false;// Clear sticky CSAI flag for next break streamInfo.EscapeHatchFired = false; // Auto-escalate cooldown: if 3+ reloads in last 2 min, triple the cooldown to reduce cascade pressure if (!streamInfo.ReloadTimestamps) streamInfo.ReloadTimestamps = []; streamInfo.ReloadTimestamps = streamInfo.ReloadTimestamps.filter(t => Date.now() - t < 300000); const recentReloads = streamInfo.ReloadTimestamps.filter(t => Date.now() - t < 300000).length; const effectiveCooldown = recentReloads >= 3 ? ReloadCooldownSeconds * 3 : ReloadCooldownSeconds; const tooSoonSinceLastReload = streamInfo.LastPlayerReload && (Date.now() - streamInfo.LastPlayerReload) < (effectiveCooldown * 1000); // CSAI-only ad break: backup was used but no segments were stripped. // Skip reload entirely — avoids CSAI cascade on ad-heavy channels. // Clearing the flag makes next m3u8 poll serve main stream seamlessly. if (!hadStrippedSegments) { console.log('[AD DEBUG] CSAI-only ad break (stripped 0) — clearing backup without player action'); streamInfo.IsUsingModifiedM3U8 = false; // Exception: if ANY backup was committed during this break (escape hatch // or cycle rescue that didn't meet cycleRescuedCleanly criteria), the // MediaSource buffer has accumulated mixed-source segments (backup-fetched // via alternate player-type access token + native-fetched). Mixing can // cause audio/video track timestamps to diverge, and without a reload the // drift compounds across subsequent escape-hatch breaks. Force a hard reload // to flush the MediaSource buffer + refresh the access token. // For autoplay (360p) specifically, the reload also restores Source // quality (autoplay-scoped token only serves 360p variant ladder). if (streamInfo.LastCommittedBackupPlayerType) { const isAutoplay = streamInfo.LastCommittedBackupPlayerType === 'autoplay'; const reason = isAutoplay ? 'autoplay (360p) — restoring Source quality' : streamInfo.LastCommittedBackupPlayerType + ' — flushing MediaSource to prevent A/V desync accumulation'; console.log('[AD DEBUG] Post-escape reload: ' + reason); streamInfo.LastPlayerReload = Date.now(); if (!streamInfo.ReloadTimestamps) streamInfo.ReloadTimestamps = []; streamInfo.ReloadTimestamps.push(Date.now()); postMessage({ key: 'ReloadPlayer', kind: 'early' }); } } else { // Skip end-of-break reload when cycle rescue handled the break cleanly: // a freeze of ≤2 polls (~4s) was resolved by switching to a clean backup, // and no early reload was needed. The player is on a healthy backup stream // — reloading just to return to the canonical player type causes an unnecessary // ~1-2s loading circle. const cycleRescuedCleanly = streamInfo.CycleRescuedThisBreak && (streamInfo.TotalAllStrippedPolls || 0) <= 2 && (streamInfo.EarlyReloadCount || 0) === 0; if (cycleRescuedCleanly) { console.log('[AD DEBUG] Cycle rescue handled the break cleanly — skipping end-of-break reload'); } // Reload if backup was used AND segments were stripped (need clean state). // Post-ad reload bypasses cooldown: it's a buffer flush tied to natural break // end, not a cascade-risk retry. The ad break cycle itself rate-limits this // path (once per break). const shouldReload = streamInfo.IsUsingModifiedM3U8 || (ReloadPlayerAfterAd && hadStrippedSegments && !cycleRescuedCleanly); console.log('[AD DEBUG] Reload decision: shouldReload=' + shouldReload + ' IsUsingModifiedM3U8=' + streamInfo.IsUsingModifiedM3U8 + ' hadStripped=' + hadStrippedSegments + ' tooSoon=' + tooSoonSinceLastReload + ' cooldown=' + effectiveCooldown + 's' + (tooSoonSinceLastReload && shouldReload ? ' (post-ad cooldown bypassed)' : '')); if (shouldReload) { streamInfo.ReloadTimestamps.push(Date.now());// Only track actual reloads, not skipped ones streamInfo.IsUsingModifiedM3U8 = false; streamInfo.LastPlayerReload = Date.now(); postMessage({ key: 'ReloadPlayer', kind: 'early' }); } else { if (tooSoonSinceLastReload) { console.log('[AD DEBUG] Skipping reload — last reload was ' + ((Date.now() - streamInfo.LastPlayerReload) / 1000).toFixed(0) + 's ago (cooldown: ' + effectiveCooldown + 's' + (recentReloads >= 3 ? ', auto-escalated from ' + recentReloads + ' reloads in 5min' : '') + ')'); } postMessage({ key: 'PauseResumePlayer' }); } }// end else (non-CSAI path) } } 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; let driftCatchUpInterval = null; let driftCatchUpTimeout = null; function startDriftCorrection(videoElement) { if (DriftCorrectionRate <= 1) return; if (driftCatchUpInterval) { clearInterval(driftCatchUpInterval); driftCatchUpInterval = null; } if (driftCatchUpTimeout) { clearTimeout(driftCatchUpTimeout); driftCatchUpTimeout = null; } videoElement.playbackRate = DriftCorrectionRate; console.log('[AD DEBUG] Drift correction: catching up at ' + DriftCorrectionRate + 'x'); driftCatchUpInterval = setInterval(() => { try { const vid = document.querySelector('video'); if (vid && vid.buffered.length > 0) { if (vid.buffered.end(vid.buffered.length - 1) - vid.currentTime <= 1) { vid.playbackRate = 1.0; console.log('[AD DEBUG] Drift correction complete — resumed normal playback speed'); clearInterval(driftCatchUpInterval); driftCatchUpInterval = null; if (driftCatchUpTimeout) { clearTimeout(driftCatchUpTimeout); driftCatchUpTimeout = null; } } } } catch { clearInterval(driftCatchUpInterval); driftCatchUpInterval = null; } }, 500); driftCatchUpTimeout = setTimeout(() => { try { videoElement.playbackRate = 1.0; } catch {} if (driftCatchUpInterval) { clearInterval(driftCatchUpInterval); driftCatchUpInterval = null; } driftCatchUpTimeout = null; }, 30000); } 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() { // Fresh player lookup every tick (avoids stale ref when Twitch restarts its own player) playerForMonitoringBuffering = null; { const playerAndState = getPlayerAndState(); if (playerAndState && playerAndState.player && playerAndState.state) { playerForMonitoringBuffering = { player: playerAndState.player, state: playerAndState.state }; // Track user pause intent on the video element (once per element) const video = playerAndState.player.getHTMLVideoElement?.(); if (video && !video.__tasIntentHooked) { video.__tasIntentHooked = true; video.addEventListener('pause', () => { if (!playerBufferState.weJustPaused || (Date.now() - playerBufferState.weJustPaused) > 2000) { playerBufferState.userPauseIntent = true; } }); video.addEventListener('play', () => { playerBufferState.userPauseIntent = false; playerBufferState.loggedPauseIntent = false; }); } } } 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 && (player.getHTMLVideoElement()?.readyState ?? 0) >= 1 && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds && !playerBufferState.inAdBreak && (!playerBufferState.lastReloadAt || Date.now() - playerBufferState.lastReloadAt >= 15000) && (!playerBufferState.lastBackupSwitchAt || Date.now() - playerBufferState.lastBackupSwitchAt >= 10000)) { 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; playerBufferState.fixAttempts = 0; playerBufferState.recoveryReloadUsed = false; playerBufferState.userPauseIntent = false; playerBufferState.loggedPauseIntent = false; //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++; // Cap: at most ONE reload per recovery window. After reloading once, // stay on pause/play until playback recovers. Prevents reload cascades. const wouldEscalate = playerBufferState.fixAttempts >= 3; const escalateToReload = wouldEscalate && (DisableReloadCap || !playerBufferState.recoveryReloadUsed); const reloadCapNote = wouldEscalate && !escalateToReload ? ' (reload cap reached, pause/play only — set twitchAdSolutions_disableReloadCap=true to bypass)' : (escalateToReload ? ' (escalating to reload)' : ''); console.log('Attempt to fix buffering position:' + playerBufferState.position + ' bufferedPosition:' + playerBufferState.bufferedPosition + ' bufferDuration:' + playerBufferState.bufferDuration + reloadCapNote); // Seek past buffer gap instead of stalling const video = player.getHTMLVideoElement?.(); if (video && video.buffered.length > 1) { for (let bi = 0; bi < video.buffered.length; bi++) { if (video.buffered.start(bi) > video.currentTime + 0.5) { console.log('[AD DEBUG] Seeking past ' + (video.buffered.start(bi) - video.currentTime).toFixed(1) + 's buffer gap'); video.currentTime = video.buffered.start(bi); startDriftCorrection(video); break; } } } if (video) { console.log('[AD DEBUG] Video state: readyState=' + video.readyState + ' networkState=' + video.networkState + ' buffered=' + (video.buffered.length > 0 ? video.buffered.end(video.buffered.length - 1).toFixed(1) : 0) + ' currentTime=' + video.currentTime.toFixed(1) + ' paused=' + video.paused); } 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; playerBufferState.recoveryReloadUsed = true; } } } else { playerBufferState.numSame = 0; playerBufferState.fixAttempts = 0; playerBufferState.recoveryReloadUsed = false; } // Detect position jump (native gap recovery) — drift to catch up. // Skip during ad breaks and 10s after: backup stream switching causes buffer gaps that trigger false jumps. // Rate-limit to once per 30s: field-observed on warn that Twitch's player.core.state.position // jumps ~60s every ~12s on reload-heavy channels (likely batch updates from m3u8 manifest // refreshes / program-date-time sync points, not real drift). Our 1.1x videoElement.playbackRate // doesn't affect state.position anyway, so re-firing every 12s is useless log spam — the catch-up // operates on currentTime which is already at live edge. Rate-limit collapses the spam to a // single drift attempt per 30s window, leaving real-drift paths (buffer-gap seek, post-reload) // unaffected since they call startDriftCorrection() directly without going through this detector. if (playerBufferState.position > 0 && position - playerBufferState.position > 5 && !playerBufferState.inAdBreak && (!playerBufferState.lastBackupSwitchAt || Date.now() - playerBufferState.lastBackupSwitchAt >= 10000) && (!playerBufferState.lastDriftStartedAt || Date.now() - playerBufferState.lastDriftStartedAt >= 30000)) { console.log('[AD DEBUG] Position jumped ' + (position - playerBufferState.position).toFixed(1) + 's — starting drift correction'); startDriftCorrection(player.getHTMLVideoElement?.()); playerBufferState.lastDriftStartedAt = Date.now(); } 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; } } // Loading-circle health check: during an ad strip+recovery loop the normal buffer monitor // is gated off (isActivelyStrippingAds), so a visibly stalled player would otherwise wait // for the worker's poll-based early reload (~10s). This catches the visible stall ~3s after // it starts and triggers a reload directly, eliminating most of the loading-circle window. if (isActivelyStrippingAds && playerForMonitoringBuffering) { try { const player = playerForMonitoringBuffering.player; const video = player?.getHTMLVideoElement?.(); if (video && !video.ended && !playerBufferState.userPauseIntent) { // Track whether the player has ever had data — distinguishes a real stall // (had data, lost data) from initial player init (never had data yet). // Without this, fresh page load + preroll causes PR #96 to misfire repeatedly // because readyState=0 is normal during init. if (video.readyState >= 3) { playerBufferState.hasHadData = true; } const isStalled = video.readyState < 3 && (video.paused || video.networkState === 2); const stallReloadCooldown = 15000; const cooldownExpired = !playerBufferState.lastAdStallReloadAt || (Date.now() - playerBufferState.lastAdStallReloadAt) > stallReloadCooldown; // Don't fire loading-circle reload if ANY reload happened recently — readyState=0 // is the expected transient state during a reload's MediaSource teardown. Without // this, an early-reload in flight can trigger a redundant loading-circle reload. const recentReload = playerBufferState.lastReloadAt && (Date.now() - playerBufferState.lastReloadAt) < stallReloadCooldown; if (isStalled && cooldownExpired && !recentReload && playerBufferState.hasHadData) { if (!playerBufferState.adStallStartAt) { playerBufferState.adStallStartAt = Date.now(); } else if ((Date.now() - playerBufferState.adStallStartAt) > 3000) { console.log('[AD DEBUG] Loading circle detected during ad break (' + ((Date.now() - playerBufferState.adStallStartAt) / 1000).toFixed(1) + 's stall, readyState=' + video.readyState + ') — early reload'); playerBufferState.lastAdStallReloadAt = Date.now(); playerBufferState.adStallStartAt = 0; // Hard reload: a stuck media player needs its MediaSource rebuilt, not just an m3u8 refetch. doTwitchPlayerTask(false, true, 'early'); } } else if (!isStalled) { playerBufferState.adStallStartAt = 0; } } } catch {} } else if (!isActivelyStrippingAds && playerBufferState.adStallStartAt) { playerBufferState.adStallStartAt = 0; } const isLive = playerForMonitoringBuffering?.state?.props?.content?.type === 'live'; if (playerBufferState.isLive && !isLive) { updateAdblockBanner({ hasAds: false }); } playerBufferState.isLive = isLive; // Force immediate tick when tab becomes visible so stalls are caught fast on return if (typeof document !== 'undefined' && !monitorPlayerBuffering.visibilityHooked) { monitorPlayerBuffering.visibilityHooked = true; document.addEventListener('visibilitychange', () => { if (!document.hidden && !monitorPlayerBuffering.pendingTick) { monitorPlayerBuffering.pendingTick = true; setTimeout(() => { monitorPlayerBuffering.pendingTick = false; monitorPlayerBuffering(); }, 100); } }); } // Catch persistent ad-break overlays (e.g. "taking an ad break / stick around") // even after hasAds has transitioned to false. updateAdblockBanner only calls // hideTwitchAdOverlays during the active ad break; some overlays have their own // lifecycle and stay visible afterwards. Running here on every monitor tick // (1-3s cadence) keeps them hidden without a dedicated interval. try { hideTwitchAdOverlays(); } catch {} // Visibility-aware backoff: poll 3x slower when tab is hidden (but NOT during PiP — user is still watching) const shouldThrottle = typeof document !== 'undefined' && document.hidden && !document.pictureInPictureElement; const nextDelay = shouldThrottle ? PlayerBufferingDelay * 3 : PlayerBufferingDelay; setTimeout(monitorPlayerBuffering, nextDelay); } // Hide Twitch's ad break / Turbo promo / stream display ad overlays when we're already blocking ads function hideTwitchAdOverlays() { if (!cachedPlayerRootDiv || !cachedPlayerRootDiv.isConnected) return; // Hide stream display ad (SDA) wrapper const sdaElements = document.querySelectorAll('[data-test-selector="sda-wrapper"]'); for (let i = 0; i < sdaElements.length; i++) { if (!sdaElements[i].dataset.tasHidden) { sdaElements[i].dataset.tasHidden = ''; sdaElements[i].style.setProperty('display', 'none', 'important'); if (!loggedSdaHide) { loggedSdaHide = true; console.log('[AD DEBUG] Hidden Twitch stream display ad'); } } } } 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'; } if (data.hasAds) { hideTwitchAdOverlays(); } } } // 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) { 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); // Grace period before logging "not found" warnings. The buffer monitor can tick // before React has finished mounting the player, leading to a false-positive // log that fires once on every page load. Only log if the null state persists // for 10+ seconds — by then React is definitely mounted and a persistent null // indicates real API drift (Twitch renamed setPlayerActive/setSrc/etc). if (!player) { if (!getPlayerAndState.firstPlayerNullAt) getPlayerAndState.firstPlayerNullAt = Date.now(); if (!getPlayerAndState.loggedNoPlayer && (Date.now() - getPlayerAndState.firstPlayerNullAt) > 10000) { getPlayerAndState.loggedNoPlayer = true; console.log('[AD DEBUG] Player not found for 10s+ — Twitch may have renamed setPlayerActive/mediaPlayerInstance'); } } else { getPlayerAndState.firstPlayerNullAt = 0;// reset on successful find } if (!playerState) { if (!getPlayerAndState.firstStateNullAt) getPlayerAndState.firstStateNullAt = Date.now(); if (!getPlayerAndState.loggedNoState && (Date.now() - getPlayerAndState.firstStateNullAt) > 10000) { getPlayerAndState.loggedNoState = true; console.log('[AD DEBUG] Player state not found for 10s+ — Twitch may have renamed setSrc/setInitialPlaybackSettings'); } } else { getPlayerAndState.firstStateNullAt = 0;// reset on successful find } return { player: player, state: playerState }; } // Pause/play or fully reload the Twitch player, preserving quality/volume settings function doTwitchPlayerTask(isPausePlay, isReload, reloadKind) { const playerAndState = getPlayerAndState(); if (!playerAndState) { 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; } const wasPaused = player.isPaused() || player.core?.paused; // Only block pause/play toggle if already paused — still allow reloads if (isPausePlay && wasPaused) { // User deliberately paused — respect their intent, don't auto-resume if (playerBufferState.userPauseIntent) { if (!playerBufferState.loggedPauseIntent) { playerBufferState.loggedPauseIntent = true; console.log('[AD DEBUG] Respecting user pause intent — skipping auto-resume'); } return; } // If WE recently called pause/play and player is still paused, retry play (stuck from autoplay policy or ad-state interference) if (playerBufferState.weJustPaused && (Date.now() - playerBufferState.weJustPaused) < 10000) { try { player.play()?.catch?.(() => {}); } catch {} } return; } if (!wasPaused) { playerBufferState.weJustPaused = 0; } playerBufferState.lastFixTime = Date.now(); playerBufferState.numSame = 0; if (isPausePlay) { player.pause(); player.play()?.catch?.(() => {}); playerBufferState.weJustPaused = Date.now(); return; } if (isReload) { // Skip reload if the player is already healthy — avoids disrupting smooth playback. // But if we're way behind live edge (e.g. after a long ad break), proceed with reload to reset latency. const video = player.getHTMLVideoElement?.(); if (video && video.readyState >= 3 && !video.paused && !video.ended) { let latencySec = 0; let latencyKnown = false; try { if (video.seekable && video.seekable.length > 0) { const seekableEnd = video.seekable.end(video.seekable.length - 1); if (Number.isFinite(seekableEnd)) { const calc = Math.max(0, seekableEnd - video.currentTime); // Sanity cap: values >1h indicate garbage from the Media Source API // (seen right after a reload while the seekable range is in a transient state). if (calc < 3600) { latencySec = calc; latencyKnown = true; } } } } catch (e) {} if (!latencyKnown) { console.log('[AD DEBUG] Latency unknown (seekable unavailable) — proceeding with reload'); } else if (latencySec > 7) { console.log('[AD DEBUG] Player playing but ' + latencySec.toFixed(1) + 's behind live — proceeding with reload to reset latency'); } else { console.log('[AD DEBUG] Skipping reload — player healthy (readyState=' + video.readyState + ', playing, latency=' + latencySec.toFixed(1) + 's)'); postTwitchWorkerMessage('ReloadSkipped'); return; } } } if (isReload) { // Snapshot player preference LS keys before reload; restored in setTimeout below. // Mirrors TTV-AB's _PLAYER_PREFERENCE_KEYS coverage. const PRESERVED_LS_KEYS = ['video-quality', 'video-muted', 'volume', 'lowLatencyModeEnabled', 'persistenceEnabled']; const lsSnapshot = {}; try { for (const k of PRESERVED_LS_KEYS) { lsSnapshot[k] = localStorage.getItem(k); } if (localStorageHookFailed && player?.core?.state) { localStorage.setItem('video-muted', JSON.stringify({default:player.core.state.muted})); localStorage.setItem('volume', player.core.state.volume); } if (player?.core?.state?.quality?.group) { localStorage.setItem('video-quality', JSON.stringify({default:player.core.state.quality.group})); } } catch {} playerBufferState.lastReloadAt = Date.now(); playerBufferState.adStallStartAt = 0;// clear stale stall timer so post-reload readyState=0 isn't attributed to pre-reload stall playerBufferState.userPauseIntent = false; playerBufferState.loggedPauseIntent = false; // playerForMonitoringBuffering re-acquired fresh every tick — no manual invalidation needed // Hard reload for 'early' (mid-break escape — fresh session gets new ad-decision bucket). // Soft reload for 'post-ad' (smooth transition, no black screen teardown). const hardReload = reloadKind === 'early'; console.log('Reloading Twitch player' + (hardReload ? ' (hard)' : ' (soft)')); playerState.setSrc({ isNewMediaPlayerInstance: hardReload, refreshAccessToken: hardReload }); postTwitchWorkerMessage('TriggeredPlayerReload'); // Resume playback with retry — only if user hadn't manually paused if (!wasPaused) { player.play()?.catch?.(() => {}); // Retry resume if play() didn't take effect setTimeout(() => { try { if (player.isPaused() && !player.core?.paused) { player.play()?.catch?.(() => {}); } } catch {} }, 1500); } // Always restore muted/volume state after reload — Chrome autoplay policy can force muted. // Block must always run: if Twitch hasn't written LS values yet (fresh session, private mode, // cleared cache), the video still needs unmute after Chrome's autoplay mute on reload. { setTimeout(() => { try { for (const [k, v] of Object.entries(lsSnapshot)) { if (v !== null) { localStorage.setItem(k, v); } } const videos = document.getElementsByTagName('video'); // Respect user's mute intent: only force-unmute if LS didn't say mute. // Twitch writes video-muted as '{"default":true}' when user muted via UI; // Chrome autoplay policy can mute even if user didn't (no LS signal). const userIntendedMute = lsSnapshot['video-muted']?.includes('"default":true'); if (videos.length > 0 && videos[0].muted && !userIntendedMute) { videos[0].muted = false; } // Correct live drift after reload. // For hard reload with large drift (>5s), hard-seek to live edge to flush // any A/V timestamp desync from strip+BLANK_MP4+recovery activity. Drift // correction at 1.1x would take minutes to catch up 30-60s of drift. // For soft reload or small drift, use existing gradual catch-up. 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 (hardReload && drift > 5 && Number.isFinite(liveEdge) && liveEdge < 3600) { console.log('[AD DEBUG] Post-hard-reload seek to live — ' + drift.toFixed(1) + 's behind, jumping to live edge to flush A/V drift'); videos[0].currentTime = liveEdge; } else if (drift > 2) { console.log('[AD DEBUG] Post-reload live drift correction: ' + drift.toFixed(1) + 's behind'); startDriftCorrection(videos[0]); } } } 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, ok: response.ok, redirected: response.redirected, type: response.type, url: response.url, 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 = maskAsNative(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); } } } } return realFetch.apply(this, arguments); }, 'fetch'); } // 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.'); } // Resume the player on tab focus if Twitch paused it during an ad on a hidden tab. // Previously also spoofed document.hidden / visibilityState / hasFocus and swallowed // the events on the capture phase. That broke other extensions that key off real // visibility (e.g. BetterTTV "Mute Invisible Player"). Resume-on-focus alone is // enough to keep playback alive across hidden→visible transitions during ads. // Sync'd with TTV-AB v6.5.0. let wasVideoPlaying = true; const visibilityChange = () => { const videos = document.getElementsByTagName('video'); if (videos.length === 0) return; if (document.hidden) { wasVideoPlaying = !videos[0].paused && !videos[0].ended; return; } if (!playerBufferState.hasStreamStarted) { playerBufferState.hasStreamStarted = true; } if (wasVideoPlaying && !videos[0].ended && videos[0].paused) { videos[0].play()?.catch?.(() => {}); } }; document.addEventListener('visibilitychange', visibilityChange); // 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 = maskAsNative(function(key, value) { if (cachedValues.has(key)) { cachedValues.set(key, value); } realSetItem.apply(this, arguments); }, 'setItem'); const realGetItem = localStorage.getItem; localStorage.getItem = maskAsNative(function(key) { if (cachedValues.has(key)) { return cachedValues.get(key); } return realGetItem.apply(this, arguments); }, 'getItem'); if (localStorage.getItem === realGetItem) { // 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 lsReloadCooldown = parseInt(localStorage.getItem('twitchAdSolutions_reloadCooldownSeconds')); if (!isNaN(lsReloadCooldown) && lsReloadCooldown >= 0) { ReloadCooldownSeconds = lsReloadCooldown; } const lsDisableReloadCap = localStorage.getItem('twitchAdSolutions_disableReloadCap'); if (lsDisableReloadCap !== null) { DisableReloadCap = lsDisableReloadCap === 'true'; } const lsDriftRate = parseFloat(localStorage.getItem('twitchAdSolutions_driftCorrectionRate')); if (!isNaN(lsDriftRate) && lsDriftRate >= 0) { DriftCorrectionRate = lsDriftRate; } const lsEarlyReload = parseInt(localStorage.getItem('twitchAdSolutions_earlyReloadPollThreshold')); if (!isNaN(lsEarlyReload) && lsEarlyReload >= 0) { EarlyReloadPollThreshold = lsEarlyReload; } const lsDisableAdSpoofing = localStorage.getItem('twitchAdSolutions_disableAdSpoofing'); if (lsDisableAdSpoofing !== null) { DisableAdSpoofing = lsDisableAdSpoofing === '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 lsPreferLow = localStorage.getItem('twitchAdSolutions_preferLowQualityBackup'); if (lsPreferLow === 'false') { PreferLowQualityBackup = false; console.log('[AD DEBUG] PreferLowQualityBackup disabled via localStorage — sticky CSAI path only, no autoplay fallback or escape hatch'); } const lsBackupSwapFirst = localStorage.getItem('twitchAdSolutions_backupSwapFirst'); if (lsBackupSwapFirst === 'false') { BackupSwapFirst = false; console.log('[AD DEBUG] BackupSwapFirst disabled via localStorage — using sticky CSAI path (strip on native stream)'); } 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(); 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'); }; })();