twitch-videoad.js text/javascript (function() { if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } 'use strict'; const ourTwitchAdSolutionsVersion = 23;// Used to prevent conflicts with outdated versions of the scripts if (typeof window.twitchAdSolutionsVersion !== 'undefined' && window.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { console.log("skipping vaft as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + window.twitchAdSolutionsVersion); window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; return; } window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; function declareOptions(scope) { scope.AdSignifier = 'stitched'; scope.ClientID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; scope.BackupPlayerTypes = [ 'embed',//Source 'popout',//Source 'autoplay',//360p //'picture-by-picture-CACHED'//360p (-CACHED is an internal suffix and is removed) ]; scope.FallbackPlayerType = 'embed'; scope.ForceAccessTokenPlayerType = 'popout'; scope.SkipPlayerReloadOnHevc = false;// If true this will skip player reload on streams which have 2k/4k quality (if you enable this and you use the 2k/4k quality setting you'll get error #4000 / #3000 / spinning wheel on chrome based browsers) scope.AlwaysReloadPlayerOnAd = false;// Always pause/play when entering/leaving ads scope.ReloadPlayerAfterAd = true;// After the ad finishes do a player reload instead of pause/play scope.PlayerReloadMinimalRequestsTime = 1600; 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 = 12000;// Minimum delay (in milliseconds) between each pause/play (this is to avoid over pressing pause/play when there are genuine buffering problems) scope.PlayerBufferingPrerollCheckEnabled = false;// Enable this if you're getting an immediate pause/play/reload as you open a stream (which is causing the stream to take longer to load). One problem with this being true is that it can cause the player to get stuck in some instances requiring the user to press pause/play scope.PlayerBufferingPrerollCheckOffset = 5;// How far the stream need to move before doing the buffering mitigation (depends on PlayerBufferingPrerollCheckEnabled being true) scope.V2API = false; scope.IsAdStrippingEnabled = true; scope.AdSegmentCache = new Map(); scope.AllSegmentsAreAdSegments = false; } let isActivelyStrippingAds = false; let localStorageHookFailed = false; const twitchWorkers = []; const workerStringConflicts = [ 'twitch', 'isVariantA'// TwitchNoSub ]; const workerStringAllow = []; const workerStringReinsert = [ 'isVariantA',// TwitchNoSub (prior to (0.9)) 'besuper/',// TwitchNoSub (0.9) '${patch_url}'// TwitchNoSub (0.9.1) ]; 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)) && !workerStringAllow.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); } else { } 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(); return !workerStringConflicts.some((x) => workerString.includes(x)) || workerStringAllow.some((x) => workerString.includes(x)) || workerStringReinsert.some((x) => workerString.includes(x)); } function hookWindowWorker() { const reinsert = getWorkersForReinsert(window.Worker); const newWorker = class Worker extends getCleanWorker(window.Worker) { constructor(twitchBlobUrl, options) { let isTwitchWorker = false; try { isTwitchWorker = new URL(twitchBlobUrl).origin.endsWith('.twitch.tv'); } catch {} if (!isTwitchWorker) { super(twitchBlobUrl, options); return; } const newBlobStr = ` const pendingFetchRequests = new Map(); ${stripAdSegments.toString()} ${getStreamUrlForResolution.toString()} ${processM3U8.toString()} ${hookWorkerFetch.toString()} ${declareOptions.toString()} ${getAccessToken.toString()} ${gqlRequest.toString()} ${parseAttributes.toString()} ${getWasmWorkerJs.toString()} ${getServerTimeFromM3u8.toString()} ${replaceServerTimeInM3u8.toString()} const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}'); declareOptions(self); GQLDeviceID = ${GQLDeviceID ? "'" + GQLDeviceID + "'" : null}; AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined}; ClientIntegrityHeader = ${ClientIntegrityHeader ? "'" + ClientIntegrityHeader + "'" : null}; ClientVersion = ${ClientVersion ? "'" + ClientVersion + "'" : null}; ClientSession = ${ClientSession ? "'" + ClientSession + "'" : null}; self.addEventListener('message', function(e) { if (e.data.key == 'UpdateClientVersion') { ClientVersion = e.data.value; } else if (e.data.key == 'UpdateClientSession') { ClientSession = e.data.value; } else if (e.data.key == 'UpdateClientId') { ClientID = e.data.value; } else if (e.data.key == 'UpdateDeviceId') { GQLDeviceID = e.data.value; } else if (e.data.key == 'UpdateClientIntegrityHeader') { ClientIntegrityHeader = e.data.value; } else if (e.data.key == 'UpdateAuthorizationHeader') { AuthorizationHeader = e.data.value; } else if (e.data.key == 'FetchResponse') { const responseData = e.data.value; if (pendingFetchRequests.has(responseData.id)) { const { resolve, reject } = pendingFetchRequests.get(responseData.id); pendingFetchRequests.delete(responseData.id); if (responseData.error) { reject(new Error(responseData.error)); } else { // Create a Response object from the response data const response = new Response(responseData.body, { status: responseData.status, statusText: responseData.statusText, headers: responseData.headers }); resolve(response); } } } else if (e.data.key == 'TriggeredPlayerReload') { HasTriggeredPlayerReload = true; } else if (e.data.key == 'SimulateAds') { SimulatedAdsDepth = e.data.value; console.log('SimulatedAdsDepth: ' + SimulatedAdsDepth); } else if (e.data.key == 'AllSegmentsAreAdSegments') { AllSegmentsAreAdSegments = !AllSegmentsAreAdSegments; console.log('AllSegmentsAreAdSegments: ' + AllSegmentsAreAdSegments); } }); hookWorkerFetch(); eval(workerString); `; super(URL.createObjectURL(new Blob([newBlobStr])), options); twitchWorkers.push(this); this.addEventListener('message', (e) => { if (e.data.key == 'UpdateAdBlockBanner') { updateAdblockBanner(e.data); } else if (e.data.key == 'PauseResumePlayer') { doTwitchPlayerTask(true, false); } else if (e.data.key == 'ReloadPlayer') { doTwitchPlayerTask(false, true); } }); this.addEventListener('message', async event => { if (event.data.key == 'FetchRequest') { const fetchRequest = event.data.value; const responseData = await handleWorkerFetchRequest(fetchRequest); this.postMessage({ key: 'FetchResponse', value: responseData }); } }); } }; let workerInstance = reinsertWorkers(newWorker, reinsert); Object.defineProperty(window, 'Worker', { get: function() { return workerInstance; }, set: function(value) { if (isValidWorker(value)) { workerInstance = value; } else { console.log('Attempt to set twitch worker denied'); } } }); } function getWasmWorkerJs(twitchBlobUrl) { const req = new XMLHttpRequest(); req.open('GET', twitchBlobUrl, false); req.overrideMimeType("text/javascript"); req.send(); return req.responseText; } function hookWorkerFetch() { console.log('hookWorkerFetch (vaft)'); const realFetch = fetch; fetch = async function(url, options) { if (typeof url === 'string') { if (AdSegmentCache.has(url)) { return new Promise(function(resolve, reject) { const send = function() { return realFetch('data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA', options).then(function(response) { resolve(response); })['catch'](function(err) { reject(err); }); }; send(); }); } 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); } }; const send = function() { return realFetch(url, options).then(function(response) { processAfter(response); })['catch'](function(err) { reject(err); }); }; send(); }); } else if (url.includes('/channel/hls/') && !url.includes('picture-by-picture')) { V2API = url.includes('/api/v2/'); const channelName = (new URL(url)).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 const tempUrl = new URL(url); tempUrl.searchParams.delete('parent_domains'); url = tempUrl.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) { StreamInfos[channelName] = streamInfo = { ChannelName: channelName, IsShowingAd: false, LastPlayerReload: 0, EncodingsM3U8: encodingsM3u8, ModifiedM3U8: null, IsUsingModifiedM3U8: false, UsherParams: (new URL(url)).search, RequestedAds: new Set(), Urls: [],// xxx.m3u8 -> { Resolution: "284x160", FrameRate: 30.0 } ResolutionList: [], BackupEncodingsM3U8Cache: [], ActiveBackupPlayerType: null, IsMidroll: false, IsStrippingAdSegments: false, NumStrippedAdSegments: 0 }; const lines = encodingsM3u8.replaceAll('\r', '').split('\n'); for (let i = 0; i < lines.length - 1; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) { const attributes = parseAttributes(lines[i]); const resolution = attributes['RESOLUTION']; if (resolution) { const resolutionInfo = { Resolution: resolution, FrameRate: attributes['FRAME-RATE'], Codecs: attributes['CODECS'], Url: lines[i + 1] }; streamInfo.Urls[lines[i + 1]] = resolutionInfo; streamInfo.ResolutionList.push(resolutionInfo); } StreamInfosByUrl[lines[i + 1]] = streamInfo; } } const nonHevcResolutionList = streamInfo.ResolutionList.filter((element) => element.Codecs.startsWith('avc') || element.Codecs.startsWith('av0')); if (AlwaysReloadPlayerOnAd || (nonHevcResolutionList.length > 0 && streamInfo.ResolutionList.some((element) => element.Codecs.startsWith('hev') || element.Codecs.startsWith('hvc')) && !SkipPlayerReloadOnHevc)) { if (nonHevcResolutionList.length > 0) { for (let i = 0; i < lines.length - 1; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF')) { const resSettings = parseAttributes(lines[i].substring(lines[i].indexOf(':') + 1)); const codecsKey = 'CODECS'; if (resSettings[codecsKey].startsWith('hev') || resSettings[codecsKey].startsWith('hvc')) { const oldResolution = resSettings['RESOLUTION']; const [targetWidth, targetHeight] = oldResolution.split('x').map(Number); const newResolutionInfo = nonHevcResolutionList.sort((a, b) => { // TODO: Take into account 'Frame-Rate' when sorting (i.e. 1080p60 vs 1080p30) const [streamWidthA, streamHeightA] = a.Resolution.split('x').map(Number); const [streamWidthB, streamHeightB] = b.Resolution.split('x').map(Number); return Math.abs((streamWidthA * streamHeightA) - (targetWidth * targetHeight)) - Math.abs((streamWidthB * streamHeightB) - (targetWidth * targetHeight)); })[0]; console.log('ModifiedM3U8 swap ' + resSettings[codecsKey] + ' to ' + newResolutionInfo.Codecs + ' oldRes:' + oldResolution + ' newRes:' + newResolutionInfo.Resolution); lines[i] = lines[i].replace(/CODECS="[^"]+"/, `CODECS="${newResolutionInfo.Codecs}"`); lines[i + 1] = newResolutionInfo.Url + ' '.repeat(i + 1);// The stream doesn't load unless each url line is unique } } } } if (nonHevcResolutionList.length > 0 || AlwaysReloadPlayerOnAd) { streamInfo.ModifiedM3U8 = lines.join('\n'); } } } streamInfo.LastPlayerReload = Date.now(); resolve(new Response(replaceServerTimeInM3u8(streamInfo.IsUsingModifiedM3U8 ? streamInfo.ModifiedM3U8 : streamInfo.EncodingsM3U8, serverTime))); } else { resolve(response); } }; const send = function() { return realFetch(url, options).then(function(response) { processAfter(response); })['catch'](function(err) { reject(err); }); }; send(); }); } } 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.length > 1 ? matches[1] : null; } const matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"'); return 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(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8; } function stripAdSegments(textStr, stripAllSegments, streamInfo) { let hasStrippedAdSegments = false; const lines = textStr.replaceAll('\r', '').split('\n'); const newAdUrl = 'https://twitch.tv'; for (let i = 0; i < lines.length; i++) { let line = lines[i]; // Remove tracking urls which appear in the overlay UI line = line .replaceAll(/(X-TV-TWITCH-AD-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`) .replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`); if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) { const segmentUrl = lines[i + 1]; if (!AdSegmentCache.has(segmentUrl)) { streamInfo.NumStrippedAdSegments++; } AdSegmentCache.set(segmentUrl, Date.now()); hasStrippedAdSegments = true; } if (line.includes(AdSignifier)) { hasStrippedAdSegments = true; } } if (hasStrippedAdSegments) { for (let i = 0; i < lines.length; i++) { // No low latency during ads (otherwise it's possible for the player to prefetch and display ad segments) if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { lines[i] = ''; } } } else { streamInfo.NumStrippedAdSegments = 0; } streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; AdSegmentCache.forEach((value, key, map) => { if (value < Date.now() - 120000) { map.delete(key); } }); return lines.join('\n'); } function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) { const encodingsLines = encodingsM3u8.replaceAll('\r', '').split('\n'); const [targetWidth, targetHeight] = resolutionInfo.Resolution.split('x').map(Number); let matchedResolutionUrl = null; let matchedFrameRate = false; let closestResolutionUrl = null; let closestResolutionDifference = Infinity; for (let i = 0; i < encodingsLines.length - 1; i++) { if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) { const attributes = parseAttributes(encodingsLines[i]); const resolution = attributes['RESOLUTION']; const frameRate = attributes['FRAME-RATE']; if (resolution) { if (resolution == resolutionInfo.Resolution && (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) { matchedResolutionUrl = encodingsLines[i + 1]; matchedFrameRate = frameRate == resolutionInfo.FrameRate; if (matchedFrameRate) { return matchedResolutionUrl; } } const [width, height] = resolution.split('x').map(Number); const difference = Math.abs((width * height) - (targetWidth * targetHeight)); if (difference < closestResolutionDifference) { closestResolutionUrl = encodingsLines[i + 1]; closestResolutionDifference = difference; } } } } return closestResolutionUrl; } async function processM3U8(url, textStr, realFetch) { const streamInfo = StreamInfosByUrl[url]; if (!streamInfo) { return textStr; } if (HasTriggeredPlayerReload) { HasTriggeredPlayerReload = false; streamInfo.LastPlayerReload = Date.now(); } const haveAdTags = textStr.includes(AdSignifier) || SimulatedAdsDepth > 0; if (haveAdTags) { streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); if (!streamInfo.IsShowingAd) { streamInfo.IsShowingAd = true; postMessage({ key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, isStrippingAdSegments: false }); } if (!streamInfo.IsMidroll) { const lines = textStr.replaceAll('\r', '').split('\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()}); break; } } } } const currentResolution = streamInfo.Urls[url]; if (!currentResolution) { console.log('Ads will leak due to missing resolution info for ' + url); return textStr; } 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' }); } 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; } for (let playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < BackupPlayerTypes.length; playerTypeIndex++) { const playerType = BackupPlayerTypes[playerTypeIndex]; const realPlayerType = playerType.replace('-CACHED', ''); 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(); const urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams); urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); const encodingsM3u8Response = await realFetch(urlInfo.href); if (encodingsM3u8Response.status === 200) { encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType] = await encodingsM3u8Response.text(); } } } catch (err) {} } 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 ((!m3u8Text.includes(AdSignifier) && (SimulatedAdsDepth == 0 || playerTypeIndex >= SimulatedAdsDepth - 1)) || (!fallbackM3u8 && playerTypeIndex >= BackupPlayerTypes.length - 1)) { backupPlayerType = playerType; backupM3u8 = m3u8Text; break; } if (isFullyCachedPlayerType) { break; } if (isDoingMinimalRequests) { backupPlayerType = playerType; backupM3u8 = m3u8Text; break; } } } } catch (err) {} } streamInfo.BackupEncodingsM3U8Cache[playerType] = null; if (isFreshM3u8) { break; } } } if (!backupM3u8 && fallbackM3u8) { backupPlayerType = FallbackPlayerType; backupM3u8 = fallbackM3u8; } if (backupM3u8) { textStr = backupM3u8; if (streamInfo.ActiveBackupPlayerType != backupPlayerType) { streamInfo.ActiveBackupPlayerType = backupPlayerType; console.log(`Blocking${(streamInfo.IsMidroll ? ' midroll ' : ' ')}ads (${backupPlayerType})`); } } // 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 (streamInfo.IsShowingAd) { console.log('Finished blocking ads'); streamInfo.IsShowingAd = false; streamInfo.IsStrippingAdSegments = false; streamInfo.NumStrippedAdSegments = 0; streamInfo.ActiveBackupPlayerType = null; if (streamInfo.IsUsingModifiedM3U8 || ReloadPlayerAfterAd) { streamInfo.IsUsingModifiedM3U8 = false; streamInfo.LastPlayerReload = Date.now(); postMessage({ key: 'ReloadPlayer' }); } else { postMessage({ key: 'PauseResumePlayer' }); } } postMessage({ key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, isStrippingAdSegments: streamInfo.IsStrippingAdSegments, numStrippedAdSegments: streamInfo.NumStrippedAdSegments }); 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]; })); } 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); } function gqlRequest(body, playerType) { if (!GQLDeviceID) { GQLDeviceID = ''; const dcharacters = 'abcdefghijklmnopqrstuvwxyz0123456789'; const dcharactersLength = dcharacters.length; for (let i = 0; i < 32; i++) { GQLDeviceID += dcharacters.charAt(Math.floor(Math.random() * dcharactersLength)); } } let headers = { 'Client-ID': ClientID, 'X-Device-Id': GQLDeviceID, 'Authorization': AuthorizationHeader, ...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader}), ...(ClientVersion && {'Client-Version': ClientVersion}), ...(ClientSession && {'Client-Session-Id': ClientSession}) }; return new Promise((resolve, reject) => { const requestId = Math.random().toString(36).substring(2, 15); const fetchRequest = { id: requestId, url: 'https://gql.twitch.tv/gql', options: { method: 'POST', body: JSON.stringify(body), headers } }; pendingFetchRequests.set(requestId, { resolve, reject }); postMessage({ key: 'FetchRequest', value: fetchRequest }); }); } let playerForMonitoringBuffering = null; const playerBufferState = { position: 0, bufferedPosition: 0, bufferDuration: 0, numSame: 0, lastFixTime: 0, isLive: true }; function monitorPlayerBuffering() { if (playerForMonitoringBuffering) { try { const player = playerForMonitoringBuffering.player; const state = playerForMonitoringBuffering.state; if (!player.core) { playerForMonitoringBuffering = null; } else if (state.props?.content?.type === 'live' && !player.isPaused() && !player.getHTMLVideoElement()?.ended && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds) { const 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); // NOTE: This could be improved. It currently lets the player fully eat the full buffer before it triggers pause/play if ((!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) { console.log('Attempt to fix buffering position:' + playerBufferState.position + ' bufferedPosition:' + playerBufferState.bufferedPosition + ' bufferDuration:' + playerBufferState.bufferDuration); const isPausePlay = !PlayerBufferingDoPlayerReload; const isReload = PlayerBufferingDoPlayerReload; doTwitchPlayerTask(isPausePlay, isReload); playerBufferState.lastFixTime = Date.now(); playerBufferState.numSame = 0; } } else { playerBufferState.numSame = 0; } playerBufferState.position = position; playerBufferState.bufferedPosition = bufferedPosition; playerBufferState.bufferDuration = bufferDuration; } else { playerBufferState.numSame = 0; } } } catch (err) { console.error('error when monitoring player for buffering: ' + err); playerForMonitoringBuffering = null; } } if (!playerForMonitoringBuffering) { const playerAndState = getPlayerAndState(); if (playerAndState && playerAndState.player && playerAndState.state) { playerForMonitoringBuffering = { player: playerAndState.player, state: playerAndState.state }; } } const isLive = playerForMonitoringBuffering?.state?.props?.content?.type === 'live'; if (playerBufferState.isLive && !isLive) { updateAdblockBanner({ hasAds: false }); } playerBufferState.isLive = isLive; setTimeout(monitorPlayerBuffering, PlayerBufferingDelay); } function updateAdblockBanner(data) { const playerRootDiv = document.querySelector('.video-player'); if (playerRootDiv != null) { let adBlockDiv = null; adBlockDiv = playerRootDiv.querySelector('.adblock-overlay'); if (adBlockDiv == null) { adBlockDiv = document.createElement('div'); adBlockDiv.className = 'adblock-overlay'; adBlockDiv.innerHTML = '