diff --git a/README.md b/README.md index 775a813..9bfc8b4 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,17 @@ To stop using a script remove the filter and make the url `unset`. *The scripts __may randomly stop being applied by uBlock Origin__ for unknown reasons ([#200](https://github.com/pixeltris/TwitchAdSolutions/issues/200)). It's recommended to use the userscript versions instead.* -**Brave currently doesn't work with the uBlock Origin scripts, use the userscript instead.** - ## Applying a script (userscript) -- Viewing one of the userscript files should prompt the given script to be added (assuming you have a userscript manager). +Viewing one of the userscript files should prompt the given script to be added when you have a userscript manager installed. + +Userscript managers: + +- https://violentmonkey.github.io/ +- https://www.tampermonkey.net/ +- https://addons.mozilla.org/en-US/firefox/addon/greasemonkey/ +- https://apps.apple.com/us/app/userscripts/id1463298887 + +## Issues with the scripts + +If the script doesn't work or you're experiencing freezing / buffering issues see [issues.md](issues.md) \ No newline at end of file diff --git a/issues.md b/issues.md new file mode 100644 index 0000000..78eca0e --- /dev/null +++ b/issues.md @@ -0,0 +1,38 @@ +# Issues with `vaft` / `video-swap-new` + +## Neither script works + +If you're using the uBlock Origin version of the script you need to make sure that it's set up correctly based on the instructions in the README. Check the script is active by opening your browsers developer console, refreshing a stream, and searching for `hookWorkerFetch (vaft)` / `hookWorkerFetch (video-swap-new)`. If you don't see this, then the script isn't being injected and you need to find the reason why. + +## Streams sometimes appear offline when ads occur + +This needs to be fixed but currently the exact cause is unknown. + +## `vaft` + +### Freezing / buffering + +The stream may have playback problems during ads. There is an integrated solution that attempts to automatically press pause/play if buffering occurs during ads. If this isn't working you could try changing `AlwaysReloadPlayerOnAd` from `false` to `true` which will trigger a player reload as it enters/leaves ads which may add some stability. If neither of those are working how you'd like you can disable the pause/play buffer fixing attempt by modifying the userscript and change `FixPlayerBufferingInsideAds` from `true` to `false`. You then need to find an alternative solution to the buffering such as the folloing: + +- https://github.com/pixeltris/TwitchAdSolutions/issues/299#issuecomment-2509500697 +- https://github.com/pixeltris/TwitchAdSolutions/issues/313 + +If you're still having problems it's recommended to switch to using `video-swap-new` (which is the recommended script in the README). + +### Audio desyncs + +This script can be susceptible to audio desyncs due to the way that it works. There currently isn't any fix for this. Try a different script / solution. + +### Freezing / buffering after ads + +You can try changing `FixPlayerBufferingOutsideAds` from `false` to `true` which will scan the player for buffering when ads aren't happening and will trigger a pause/play which may work for you. + +## `video-swap-new` + +### Long black screen during ads + +The script reloads the player as it enters/leaves ads to switch the active m3u8 file that is being used. On some systems this may cause a long period of time where the player is black as the player is loading back in. There currently isn't any fix for this. Try a different script / solution. *This also applies to `vaft` where the streamer has a 2k/4k quality setting as a player reload is required to handle these.* + +### Freezing / buffering during ads + +Generally `video-swap-new` should be less susceptible to buffering / freezing than `vaft` as all it does is switch the active m3u8 file as it enters / leaves ads. There currently isn't a fix for freezing / buffering issues for `video-swap-new`. Try a different script / solution. diff --git a/vaft/vaft-ublock-origin.js b/vaft/vaft-ublock-origin.js index b099745..6e84126 100644 --- a/vaft/vaft-ublock-origin.js +++ b/vaft/vaft-ublock-origin.js @@ -2,40 +2,44 @@ twitch-videoad.js text/javascript (function() { if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } 'use strict'; - var ourTwitchAdSolutionsVersion = 11;// Used to prevent conflicts with outdated versions of the scripts - if (typeof unsafeWindow === 'undefined') { - unsafeWindow = window; - } - if (typeof unsafeWindow.twitchAdSolutionsVersion !== 'undefined' && unsafeWindow.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { - console.log("skipping vaft as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + unsafeWindow.twitchAdSolutionsVersion); - unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + var ourTwitchAdSolutionsVersion = 14;// 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; } - unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; function declareOptions(scope) { scope.AdSignifier = 'stitched'; scope.ClientID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - scope.ClientVersion = 'null'; - scope.ClientSession = 'null'; - scope.PlayerType2 = 'embed'; //Source - scope.PlayerType3 = 'site'; //Source - scope.PlayerType4 = 'autoplay'; //360p - scope.CurrentChannelName = null; - scope.UsherParams = null; - scope.WasShowingAd = false; - scope.GQLDeviceID = null; - scope.IsSquadStream = false; + scope.BackupPlayerTypes = [ + 'embed',//Source + 'site',//Source + 'autoplay'//360p + ]; + scope.FallbackPlayerType = 'embed'; + scope.ForceAccessTokenPlayerType = 'site'; + 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; + scope.PlayerReloadLowResTime = 1500; scope.StreamInfos = []; scope.StreamInfosByUrl = []; - scope.MainUrlByUrl = []; - scope.EncodingCacheTimeout = 60000; + scope.GQLDeviceID = null; + scope.ClientVersion = null; + scope.ClientSession = null; scope.ClientIntegrityHeader = null; - scope.AuthorizationHeader = null; + scope.AuthorizationHeader = undefined; + scope.SimulatedAdsDepth = 0; + scope.IsPlayerBuffering = false; + scope.LastPausePlay = 0; + scope.FixPlayerBufferingInsideAds = true; + scope.FixPlayerBufferingOutsideAds = false; + scope.DelayBetweenEachPlayerFixBufferAttempt = 1500; + scope.ActiveStreamInfo = null; } + var localStorageHookFailed = false; var twitchWorkers = []; var adBlockDiv = null; - var OriginalVideoPlayerQuality = null; - var IsPlayerAutoQuality = null; var workerStringConflicts = [ 'twitch', 'isVariantA'// TwitchNoSub @@ -94,8 +98,8 @@ twitch-videoad.js text/javascript || workerStringReinsert.some((x) => workerString.includes(x)); } function hookWindowWorker() { - var reinsert = getWorkersForReinsert(unsafeWindow.Worker); - var newWorker = class Worker extends getCleanWorker(unsafeWindow.Worker) { + var reinsert = getWorkersForReinsert(window.Worker); + var newWorker = class Worker extends getCleanWorker(window.Worker) { constructor(twitchBlobUrl, options) { var isTwitchWorker = false; try { @@ -108,23 +112,25 @@ twitch-videoad.js text/javascript var newBlobStr = ` const pendingFetchRequests = new Map(); ${getStreamUrlForResolution.toString()} - ${getStreamForResolution.toString()} - ${stripUnusedParams.toString()} ${processM3U8.toString()} ${hookWorkerFetch.toString()} ${declareOptions.toString()} ${getAccessToken.toString()} ${gqlRequest.toString()} - ${adRecordgqlPacket.toString()} - ${tryNotifyTwitch.toString()} ${parseAttributes.toString()} ${getWasmWorkerJs.toString()} + ${getServerTimeFromM3u8.toString()} + ${replaceServerTimeInM3u8.toString()} + ${tryFixPlayerBuffering.toString()} var 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 == 'UpdateIsSquadStream') { - IsSquadStream = e.data.value; - } else if (e.data.key == 'UpdateClientVersion') { + if (e.data.key == 'UpdateClientVersion') { ClientVersion = e.data.value; } else if (e.data.key == 'UpdateClientSession') { ClientSession = e.data.value; @@ -153,6 +159,20 @@ twitch-videoad.js text/javascript resolve(response); } } + } else if (e.data.key == 'SimulateAds') { + SimulatedAdsDepth = e.data.value; + console.log('SimulatedAdsDepth:' + SimulatedAdsDepth); + } + if (e.data.funcName) { + if (e.data.funcName == 'onClientSinkBuffering') { + IsPlayerBuffering = true; + } else if (e.data.funcName == 'onClientSinkPlaying') { + IsPlayerBuffering = false; + LastPausePlay = Date.now(); + } else if (e.data.funcName == 'playIntent' || e.data.funcName == 'play') { + LastPausePlay = Date.now(); + } + tryFixPlayerBuffering(); } }); hookWorkerFetch(); @@ -165,105 +185,21 @@ twitch-videoad.js text/javascript if (adBlockDiv == null) { adBlockDiv = getAdBlockDiv(); } - adBlockDiv.P.textContent = 'Blocking ads'; - adBlockDiv.style.display = 'block'; + if (adBlockDiv != null) { + adBlockDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads'; + adBlockDiv.style.display = 'block'; + } } else if (e.data.key == 'HideAdBlockBanner') { if (adBlockDiv == null) { adBlockDiv = getAdBlockDiv(); } - adBlockDiv.style.display = 'none'; - } else if (e.data.key == 'PauseResumePlayer') { - doTwitchPlayerTask(true, false, false, false, false); - } else if (e.data.key == 'ForceChangeQuality') { - //This is used to fix the bug where the video would freeze. - try { - //if (navigator.userAgent.toLowerCase().indexOf('firefox') == -1) { - return; - //} - var autoQuality = doTwitchPlayerTask(false, false, false, true, false); - var currentQuality = doTwitchPlayerTask(false, true, false, false, false); - if (IsPlayerAutoQuality == null) { - IsPlayerAutoQuality = autoQuality; - } - if (OriginalVideoPlayerQuality == null) { - OriginalVideoPlayerQuality = currentQuality; - } - if (!currentQuality.includes('360') || e.data.value != null) { - if (!OriginalVideoPlayerQuality.includes('360')) { - var settingsMenu = document.querySelector('div[data-a-target="player-settings-menu"]'); - if (settingsMenu == null) { - var settingsCog = document.querySelector('button[data-a-target="player-settings-button"]'); - if (settingsCog) { - settingsCog.click(); - var qualityMenu = document.querySelector('button[data-a-target="player-settings-menu-item-quality"]'); - if (qualityMenu) { - qualityMenu.click(); - } - var lowQuality = document.querySelectorAll('input[data-a-target="tw-radio"'); - if (lowQuality) { - var qualityToSelect = lowQuality.length - 2; - if (e.data.value != null) { - if (e.data.value.includes('original')) { - e.data.value = OriginalVideoPlayerQuality; - if (IsPlayerAutoQuality) { - e.data.value = 'auto'; - } - } - if (e.data.value.includes('160p')) { - qualityToSelect = 5; - } - if (e.data.value.includes('360p')) { - qualityToSelect = 4; - } - if (e.data.value.includes('480p')) { - qualityToSelect = 3; - } - if (e.data.value.includes('720p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('822p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('864p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('900p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('936p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('960p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('1080p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('source')) { - qualityToSelect = 1; - } - if (e.data.value.includes('auto')) { - qualityToSelect = 0; - } - } - var currentQualityLS = unsafeWindow.localStorage.getItem('video-quality'); - lowQuality[qualityToSelect].click(); - settingsCog.click(); - unsafeWindow.localStorage.setItem('video-quality', currentQualityLS); - if (e.data.value != null) { - OriginalVideoPlayerQuality = null; - IsPlayerAutoQuality = null; - doTwitchPlayerTask(false, false, false, true, true); - } - } - } - } - } - } - } catch (err) { - OriginalVideoPlayerQuality = null; - IsPlayerAutoQuality = null; + if (adBlockDiv != null) { + adBlockDiv.style.display = 'none'; } + } else if (e.data.key == 'PauseResumePlayer') { + doTwitchPlayerTask(true, false); + } else if (e.data.key == 'ReloadPlayer') { + doTwitchPlayerTask(false, true); } }); this.addEventListener('message', async event => { @@ -296,7 +232,7 @@ twitch-videoad.js text/javascript } }; var workerInstance = reinsertWorkers(newWorker, reinsert); - Object.defineProperty(unsafeWindow, 'Worker', { + Object.defineProperty(window, 'Worker', { get: function() { return workerInstance; }, @@ -321,24 +257,12 @@ twitch-videoad.js text/javascript var realFetch = fetch; fetch = async function(url, options) { if (typeof url === 'string') { + url = url.trimEnd(); if (url.endsWith('m3u8')) { return new Promise(function(resolve, reject) { var processAfter = async function(response) { if (response.status === 200) { - //Here we check the m3u8 for any ads and also try fallback player types if needed. - var responseText = await response.text(); - var weaverText = null; - var fallbackWeaverText = weaverText = await processM3U8(url, responseText, realFetch, PlayerType2); - if (weaverText.includes(AdSignifier)) { - weaverText = await processM3U8(url, responseText, realFetch, PlayerType3); - } - if (weaverText.includes(AdSignifier)) { - weaverText = await processM3U8(url, responseText, realFetch, PlayerType4); - } - if (weaverText.includes(AdSignifier)) { - weaverText = fallbackWeaverText; - } - resolve(new Response(weaverText)); + resolve(new Response(await processM3U8(url, await response.text(), realFetch))); } else { resolve(response); } @@ -352,48 +276,97 @@ twitch-videoad.js text/javascript }; send(); }); - } else if (url.includes('/api/channel/hls/')) { + } else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - UsherParams = (new URL(url)).search; - CurrentChannelName = channelName; - //To prevent pause/resume loop for mid-rolls. - var isPBYPRequest = url.includes('picture-by-picture'); - if (isPBYPRequest) { - url = ''; + if (ForceAccessTokenPlayerType) { + // parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads + var tempUrl = new URL(url); + tempUrl.searchParams.delete('parent_domains'); + url = tempUrl.toString(); } return new Promise(function(resolve, reject) { var processAfter = async function(response) { if (response.status == 200) { - encodingsM3u8 = await response.text(); + var encodingsM3u8 = await response.text(); + var serverTime = getServerTimeFromM3u8(encodingsM3u8); var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; + 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; } - streamInfo.ChannelName = channelName; - streamInfo.RequestedAds = new Set(); - streamInfo.Urls = [];// xxx.m3u8 -> { Resolution: "284x160", FrameRate: 30.0 } - streamInfo.EncodingsM3U8Cache = []; - streamInfo.EncodingsM3U8 = encodingsM3u8; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls[lines[i]] = -1; - if (i > 0 && lines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var attributes = parseAttributes(lines[i - 1]); + if (streamInfo == null || streamInfo.EncodingsM3U8 == null) { + StreamInfos[channelName] = streamInfo = { + ChannelName: channelName, + IsShowingAd: false, + AdStartTime: 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 + }; + var lines = encodingsM3u8.replace('\r', '').split('\n'); + for (var i = 0; i < lines.length - 1; i++) { + if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) { + var attributes = parseAttributes(lines[i]); var resolution = attributes['RESOLUTION']; - var frameRate = attributes['FRAME-RATE']; if (resolution) { - streamInfo.Urls[lines[i]] = { + var resolutionInfo = { Resolution: resolution, - FrameRate: frameRate + 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; + } + } + var 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 (var i = 0; i < lines.length - 1; i++) { + if (lines[i].startsWith('#EXT-X-STREAM-INF')) { + var resSettings = parseAttributes(lines[i].substring(lines[i].indexOf(':') + 1)); + const codecsKey = 'CODECS'; + if (resSettings[codecsKey].startsWith('hev') || resSettings[codecsKey].startsWith('hvc')) { + var oldResolution = resSettings['RESOLUTION']; + const [targetWidth, targetHeight] = oldResolution.split('x').map(Number); + var 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'); + var streamM3u8Url = streamInfo.EncodingsM3U8.match(/^https:.*\.m3u8$/m)[0]; + var streamM3u8Response = await realFetch(streamM3u8Url); + if (streamM3u8Response.status == 200) { + var streamM3u8 = await streamM3u8Response.text(); + if (streamM3u8.includes(AdSignifier) || SimulatedAdsDepth > 0) { + streamInfo.IsUsingModifiedM3U8 = true; + } } } - StreamInfosByUrl[lines[i]] = streamInfo; - MainUrlByUrl[lines[i]] = url; } + } else { + streamInfo.IsUsingModifiedM3U8 = streamInfo.IsShowingAd && streamInfo.ModifiedM3U8; } - resolve(new Response(encodingsM3u8)); + resolve(new Response(replaceServerTimeInM3u8(streamInfo.IsUsingModifiedM3U8 ? streamInfo.ModifiedM3U8 : streamInfo.EncodingsM3U8, serverTime))); } else { resolve(response); } @@ -412,207 +385,185 @@ twitch-videoad.js text/javascript return realFetch.apply(this, arguments); }; } - function getStreamUrlForResolution(encodingsM3u8, resolutionInfo, qualityOverrideStr) { - var qualityOverride = 0; - if (qualityOverrideStr && qualityOverrideStr.endsWith('p')) { - qualityOverride = qualityOverrideStr.substr(0, qualityOverrideStr.length - 1) | 0; - } - var qualityOverrideFoundQuality = 0; - var qualityOverrideFoundFrameRate = 0; + function getServerTimeFromM3u8(encodingsM3u8) { + var matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"'); + return matches.length > 1 ? matches[1] : null; + } + function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) { + return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8; + } + function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) { var encodingsLines = encodingsM3u8.replace('\r', '').split('\n'); - var firstUrl = null; - var lastUrl = null; + const [targetWidth, targetHeight] = resolutionInfo.Resolution.split('x').map(Number); var matchedResolutionUrl = null; var matchedFrameRate = false; - for (var i = 0; i < encodingsLines.length; i++) { - if (!encodingsLines[i].startsWith('#') && encodingsLines[i].includes('.m3u8')) { - if (i > 0 && encodingsLines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var attributes = parseAttributes(encodingsLines[i - 1]); - var resolution = attributes['RESOLUTION']; - var frameRate = attributes['FRAME-RATE']; - if (resolution) { - if (qualityOverride) { - var quality = resolution.toLowerCase().split('x')[1]; - if (quality == qualityOverride) { - qualityOverrideFoundQuality = quality; - qualityOverrideFoundFrameRate = frameRate; - matchedResolutionUrl = encodingsLines[i]; - if (frameRate < 40) { - //console.log(`qualityOverride(A) quality:${quality} frameRate:${frameRate}`); - return matchedResolutionUrl; - } - } else if (quality < qualityOverride) { - //if (matchedResolutionUrl) { - // console.log(`qualityOverride(B) quality:${qualityOverrideFoundQuality} frameRate:${qualityOverrideFoundFrameRate}`); - //} else { - // console.log(`qualityOverride(C) quality:${quality} frameRate:${frameRate}`); - //} - return matchedResolutionUrl ? matchedResolutionUrl : encodingsLines[i]; - } - } else if ((!resolutionInfo || resolution == resolutionInfo.Resolution) && - (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) { - matchedResolutionUrl = encodingsLines[i]; - matchedFrameRate = frameRate == resolutionInfo.FrameRate; - if (matchedFrameRate) { - return matchedResolutionUrl; - } + var closestResolutionUrl = null; + var closestResolutionDifference = Infinity; + for (var i = 0; i < encodingsLines.length - 1; i++) { + if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) { + var attributes = parseAttributes(encodingsLines[i]); + var resolution = attributes['RESOLUTION']; + var 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; } } - if (firstUrl == null) { - firstUrl = encodingsLines[i]; + const [width, height] = resolution.split('x').map(Number); + var difference = Math.abs((width * height) - (targetWidth * targetHeight)); + if (difference < closestResolutionDifference) { + closestResolutionUrl = encodingsLines[i + 1]; + closestResolutionDifference = difference; } - lastUrl = encodingsLines[i]; } } } - if (qualityOverride) { - return lastUrl; - } - return matchedResolutionUrl ? matchedResolutionUrl : firstUrl; + return closestResolutionUrl; } - async function getStreamForResolution(streamInfo, resolutionInfo, encodingsM3u8, fallbackStreamStr, playerType, realFetch) { - var qualityOverride = null; - if (streamInfo.EncodingsM3U8Cache[playerType].Resolution != resolutionInfo.Resolution || - streamInfo.EncodingsM3U8Cache[playerType].RequestTime < Date.now() - EncodingCacheTimeout) { - console.log(`Blocking ads (type:${playerType}, resolution:${resolutionInfo.Resolution}, frameRate:${resolutionInfo.FrameRate}, qualityOverride:${qualityOverride})`); - } - streamInfo.EncodingsM3U8Cache[playerType].RequestTime = Date.now(); - streamInfo.EncodingsM3U8Cache[playerType].Value = encodingsM3u8; - streamInfo.EncodingsM3U8Cache[playerType].Resolution = resolutionInfo.Resolution; - var streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, resolutionInfo, qualityOverride); - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - var m3u8Text = await streamM3u8Response.text(); - WasShowingAd = true; + function tryFixPlayerBuffering() { + // NOTE: This "ActiveStreamInfo" variable isn't ideal but now that squad streams are removed it should be correct + if (IsPlayerBuffering && LastPausePlay < Date.now() - DelayBetweenEachPlayerFixBufferAttempt && ActiveStreamInfo && + ((ActiveStreamInfo.IsShowingAd && FixPlayerBufferingInsideAds) || (!ActiveStreamInfo.IsShowingAd && FixPlayerBufferingOutsideAds)) + ) { + console.log("Attempting to fix player buffering by doing pause/play"); + LastPausePlay = Date.now(); postMessage({ - key: 'ShowAdBlockBanner' + key: 'PauseResumePlayer' }); - postMessage({ - key: 'ForceChangeQuality' - }); - if (!m3u8Text || m3u8Text.includes(AdSignifier)) { - streamInfo.EncodingsM3U8Cache[playerType].Value = null; - } - return m3u8Text; - } else { - streamInfo.EncodingsM3U8Cache[playerType].Value = null; - return fallbackStreamStr; } } - function stripUnusedParams(str, params) { - if (!params) { - params = [ 'token', 'sig' ]; - } - var tempUrl = new URL('https://localhost/' + str); - for (var i = 0; i < params.length; i++) { - tempUrl.searchParams.delete(params[i]); - } - return tempUrl.pathname.substring(1) + tempUrl.search; - } - async function processM3U8(url, textStr, realFetch, playerType) { - //Checks the m3u8 for ads and if it finds one, instead returns an ad-free stream. + async function processM3U8(url, textStr, realFetch) { var streamInfo = StreamInfosByUrl[url]; - //Ad blocking for squad streams is disabled due to the way multiple weaver urls are used. No workaround so far. - if (IsSquadStream == true) { + if (!streamInfo) { return textStr; } - if (!textStr) { - return textStr; - } - //Some live streams use mp4. - if (!textStr.includes('.ts') && !textStr.includes('.mp4')) { - return textStr; - } - var haveAdTags = textStr.includes(AdSignifier); + ActiveStreamInfo = streamInfo; + var haveAdTags = textStr.includes(AdSignifier) || SimulatedAdsDepth > 0; if (haveAdTags) { - var isMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); - //Reduces ad frequency. TODO: Reduce the number of requests. This is really spamming Twitch with requests. - if (!isMidroll) { - if (playerType === PlayerType2) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - var 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; - } + streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); + if (!streamInfo.IsMidroll) { + var lines = textStr.replace('\r', '').split('\n'); + for (var i = 0; i < lines.length; i++) { + var 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; } } } - try { - //tryNotifyTwitch(textStr); - } catch (err) {} } - var currentResolution = null; - if (streamInfo && streamInfo.Urls) { - for (const [resUrl, resInfo] of Object.entries(streamInfo.Urls)) { - if (resUrl == url) { - currentResolution = resInfo; - //console.log(resInfo.Resolution); + streamInfo.Resolution + var currentResolution = streamInfo.Urls[url]; + if (!currentResolution) { + console.log('Ads will leak due to missing resolution info for ' + url); + return textStr; + } + if (streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) { + streamInfo.AdStartTime = Date.now(); + } + if (!streamInfo.IsShowingAd) { + streamInfo.AdStartTime = Date.now(); + streamInfo.IsShowingAd = true; + if (streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) { + postMessage({ + key: 'ReloadPlayer' + }); + } + postMessage({ + key: 'ShowAdBlockBanner', + isMidroll: streamInfo.IsMidroll + }); + } + var backupPlayerType = null; + var backupM3u8 = null; + var fallbackM3u8 = null; + var startIndex = 0; + if ((streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) || + (streamInfo.ModifiedM3U8 && streamInfo.AdStartTime > Date.now() - PlayerReloadLowResTime) + ) { + // When doing player reload there are a lot of requests which causes the backup stream to load in slow. Briefly prefer using the low res version to prevent long delays + startIndex = BackupPlayerTypes.length - 1; + } + for (var playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < BackupPlayerTypes.length; playerTypeIndex++) { + var playerType = BackupPlayerTypes[playerTypeIndex]; + for (var 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) + var isFreshM3u8 = false; + var encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType]; + if (!encodingsM3u8) { + isFreshM3u8 = true; + try { + var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, playerType); + if (accessTokenResponse.status === 200) { + var accessToken = await accessTokenResponse.json(); + var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams); + urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); + urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); + var encodingsM3u8Response = await realFetch(urlInfo.href); + if (encodingsM3u8Response.status === 200) { + encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType] = await encodingsM3u8Response.text(); + } + } + } catch (err) {} + } + if (encodingsM3u8) { + try { + var streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, currentResolution); + var streamM3u8Response = await realFetch(streamM3u8Url); + if (streamM3u8Response.status == 200) { + var m3u8Text = await streamM3u8Response.text(); + if (m3u8Text) { + if (playerType == FallbackPlayerType) { + fallbackM3u8 = m3u8Text; + } + if (!m3u8Text.includes(AdSignifier) && (SimulatedAdsDepth == 0 || playerTypeIndex >= SimulatedAdsDepth - 1)) { + backupPlayerType = playerType; + backupM3u8 = m3u8Text; + break; + } + } + } + } catch (err) {} + } + if (startIndex != 0) { + break; + } + streamInfo.BackupEncodingsM3U8Cache[playerType] = null; + if (isFreshM3u8) { break; } } } - // Keep the m3u8 around for a little while (once per ad) before requesting a new one - var encodingsM3U8Cache = streamInfo.EncodingsM3U8Cache[playerType]; - if (encodingsM3U8Cache) { - if (encodingsM3U8Cache.Value && encodingsM3U8Cache.RequestTime >= Date.now() - EncodingCacheTimeout) { - try { - var result = getStreamForResolution(streamInfo, currentResolution, encodingsM3U8Cache.Value, null, playerType, realFetch); - if (result) { - return result; - } - } catch (err) { - encodingsM3U8Cache.Value = null; - } + 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})`); } - } else { - streamInfo.EncodingsM3U8Cache[playerType] = { - RequestTime: Date.now(), - Value: null, - Resolution: null - }; } - var accessTokenResponse = await getAccessToken(CurrentChannelName, playerType); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - try { - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + CurrentChannelName + '.m3u8' + UsherParams); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - return getStreamForResolution(streamInfo, currentResolution, await encodingsM3u8Response.text(), textStr, playerType, realFetch); - } else { - return textStr; - } - } catch (err) {} - return textStr; - } else { - return textStr; - } - } else { - if (WasShowingAd) { - console.log('Finished blocking ads'); - WasShowingAd = false; - //Here we put player back to original quality and remove the blocking message. - postMessage({ - key: 'ForceChangeQuality', - value: 'original' - }); - postMessage({ - key: 'PauseResumePlayer' - }); - postMessage({ - key: 'HideAdBlockBanner' - }); - } - return textStr; + } else if (streamInfo.IsShowingAd) { + console.log('Finished blocking ads'); + streamInfo.IsShowingAd = false; + streamInfo.ActiveBackupPlayerType = null; + postMessage({ + key: streamInfo.IsUsingModifiedM3U8 ? 'ReloadPlayer' : 'PauseResumePlayer' + }); + postMessage({ + key: 'HideAdBlockBanner' + }); } + tryFixPlayerBuffering(); return textStr; } function parseAttributes(str) { @@ -627,69 +578,6 @@ twitch-videoad.js text/javascript return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num]; })); } - async function tryNotifyTwitch(streamM3u8) { - //We notify that an ad was requested but was not visible and was also muted. - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: true, - player_volume: 0.0, - visible: false, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 0, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(adRecordgqlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - adRecordgqlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(adRecordgqlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - function adRecordgqlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } function getAccessToken(channelName, playerType) { var body = null; var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "android", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "android", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; @@ -716,12 +604,12 @@ twitch-videoad.js text/javascript } var headers = { 'Client-ID': ClientID, - 'Client-Integrity': ClientIntegrityHeader, 'Device-ID': GQLDeviceID, 'X-Device-Id': GQLDeviceID, - 'Client-Version': ClientVersion, - 'Client-Session-Id': ClientSession, - 'Authorization': AuthorizationHeader + '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); @@ -744,180 +632,103 @@ twitch-videoad.js text/javascript }); }); } - function doTwitchPlayerTask(isPausePlay, isCheckQuality, isCorrectBuffer, isAutoQuality, setAutoQuality) { - //This will do an instant pause/play to return to original quality once the ad is finished. - //We also use this function to get the current video player quality set by the user. - //We also use this function to quickly pause/play the player when switching tabs to stop delays. - try { - var videoController = null; - var videoPlayer = null; - 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 doTwitchPlayerTask(isPausePlay, isReload) { + function findReactNode(root, constraint) { + if (root.stateNode && constraint(root.stateNode)) { + return root.stateNode; } - function findReactRootNode() { - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; + let node = root.child; + while (node) { + const result = findReactNode(node, constraint); + if (result) { + return result; } - if (reactRootNode == null) { - var containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer')); - if (containerName != null) { - reactRootNode = rootNode[containerName]; - } - } - return reactRootNode; + node = node.sibling; } - var reactRootNode = findReactRootNode(); - if (!reactRootNode) { - console.log('Could not find react root'); - return; + return null; + } + function findReactRootNode() { + var reactRootNode = null; + var rootNode = document.querySelector('#root'); + if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { + reactRootNode = rootNode._reactRootContainer._internalRoot.current; } - videoPlayer = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - videoPlayer = videoPlayer && videoPlayer.props && videoPlayer.props.mediaPlayerInstance ? videoPlayer.props.mediaPlayerInstance : null; - if (isPausePlay) { - videoPlayer.pause(); - videoPlayer.play(); - return; - } - if (isCheckQuality) { - if (typeof videoPlayer.getQuality() == 'undefined') { - return; - } - var playerQuality = JSON.stringify(videoPlayer.getQuality()); - if (playerQuality) { - return playerQuality; - } else { - return; + if (reactRootNode == null) { + var containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer')); + if (containerName != null) { + reactRootNode = rootNode[containerName]; } } - if (isAutoQuality) { - if (typeof videoPlayer.isAutoQualityMode() == 'undefined') { - return false; - } - var autoQuality = videoPlayer.isAutoQualityMode(); - if (autoQuality) { - videoPlayer.setAutoQualityMode(false); - return autoQuality; - } else { - return false; - } + return reactRootNode; + } + var reactRootNode = findReactRootNode(); + if (!reactRootNode) { + console.log('Could not find react root'); + return; + } + var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); + player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; + var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); + if (!player) { + console.log('Could not find player'); + return; + } + if (!playerState) { + console.log('Could not find player state'); + return; + } + if (player.paused || player.core?.paused) { + return; + } + if (isPausePlay) { + player.pause(); + player.play(); + return; + } + if (isReload) { + const lsKeyQuality = 'video-quality'; + const lsKeyMuted = 'video-muted'; + const lsKeyVolume = 'volume'; + var currentQualityLS = localStorage.getItem(lsKeyQuality); + var currentMutedLS = localStorage.getItem(lsKeyMuted); + var currentVolumeLS = localStorage.getItem(lsKeyVolume); + if (localStorageHookFailed && player?.core?.state) { + localStorage.setItem(lsKeyMuted, JSON.stringify({default:player.core.state.muted})); + localStorage.setItem(lsKeyVolume, player.core.state.volume); } - if (setAutoQuality) { - videoPlayer.setAutoQualityMode(true); - return; + if (localStorageHookFailed && player?.core?.state?.quality?.group) { + localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group})); } - //This only happens when switching tabs and is to correct the high latency caused when opening background tabs and going to them at a later time. - //We check that this is a live stream by the page URL, to prevent vod/clip pause/plays. - try { - var currentPageURL = document.URL; - var isLive = true; - if (currentPageURL.includes('videos/') || currentPageURL.includes('clip/')) { - isLive = false; - } - if (isCorrectBuffer && isLive) { - //A timer is needed due to the player not resuming without it. - setTimeout(function() { - //If latency to broadcaster is above 5 or 15 seconds upon switching tabs, we pause and play the player to reset the latency. - //If latency is between 0-6, user can manually pause and resume to reset latency further. - if (videoPlayer.isLiveLowLatency() && videoPlayer.getLiveLatency() > 5) { - videoPlayer.pause(); - videoPlayer.play(); - } else if (videoPlayer.getLiveLatency() > 15) { - videoPlayer.pause(); - videoPlayer.play(); - } - }, 3000); - } - } catch (err) {} - } catch (err) {} + playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true }); + player.play(); + if (localStorageHookFailed) { + setTimeout(() => { + localStorage.setItem(lsKeyQuality, currentQualityLS); + localStorage.setItem(lsKeyMuted, currentMutedLS); + localStorage.setItem(lsKeyVolume, currentVolumeLS); + }, 3000); + } + return; + } } - unsafeWindow.reloadTwitchPlayer = doTwitchPlayerTask; - var localDeviceID = null; - localDeviceID = unsafeWindow.localStorage.getItem('local_copy_unique_id'); + window.reloadTwitchPlayer = doTwitchPlayerTask; function postTwitchWorkerMessage(key, value) { twitchWorkers.forEach((worker) => { worker.postMessage({key: key, value: value}); }); } - function makeGmXmlHttpRequest(fetchRequest) { - return new Promise((resolve, reject) => { - GM.xmlHttpRequest({ - method: fetchRequest.options.method, - url: fetchRequest.url, - data: fetchRequest.options.body, - headers: fetchRequest.options.headers, - onload: response => resolve(response), - onerror: error => reject(error) - }); - }); - } - // Taken from https://github.com/dimdenGD/YeahTwitter/blob/9e0520f5abe029f57929795d8de0d2e5d3751cf3/us.js#L48 - function parseHeaders(headersString) { - const headers = new Headers(); - const lines = headersString.trim().split(/[\r\n]+/); - lines.forEach(line => { - const parts = line.split(':'); - const header = parts.shift(); - const value = parts.join(':'); - headers.append(header, value); - }); - return headers; - } - var serverLikesThisBrowser = false; - var serverHatesThisBrowser = false; async function handleWorkerFetchRequest(fetchRequest) { try { - if (serverLikesThisBrowser || !serverHatesThisBrowser) { - const response = await unsafeWindow.realFetch(fetchRequest.url, fetchRequest.options); - const responseBody = await response.text(); - const responseObject = { - id: fetchRequest.id, - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()), - body: responseBody - }; - if (responseObject.status === 200) { - var resp = JSON.parse(responseBody); - if (typeof resp.errors !== 'undefined') { - serverHatesThisBrowser = true; - } else { - serverLikesThisBrowser = true; - } - } - if (serverLikesThisBrowser || !serverHatesThisBrowser) { - return responseObject; - } - } - if (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest !== 'undefined') { - fetchRequest.options.headers['Sec-Ch-Ua'] = '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"'; - fetchRequest.options.headers['Referer'] = 'https://www.twitch.tv/'; - fetchRequest.options.headers['Origin'] = 'https://www.twitch.tv/'; - fetchRequest.options.headers['Host'] = 'gql.twitch.tv'; - const response = await makeGmXmlHttpRequest(fetchRequest); - const responseBody = response.responseText; - const responseObject = { - id: fetchRequest.id, - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(parseHeaders(response.responseHeaders).entries()), - body: responseBody - }; - return responseObject; - } - throw { message: 'Failed to resolve GQL request. Try the userscript version of the ad blocking solution' }; + const response = await window.realFetch(fetchRequest.url, fetchRequest.options); + const responseBody = await response.text(); + const responseObject = { + id: fetchRequest.id, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseBody + }; + return responseObject; } catch (error) { return { id: fetchRequest.id, @@ -926,86 +737,52 @@ twitch-videoad.js text/javascript } } function hookFetch() { - var realFetch = unsafeWindow.fetch; - unsafeWindow.realFetch = realFetch; - unsafeWindow.fetch = function(url, init, ...args) { + var realFetch = window.fetch; + window.realFetch = realFetch; + window.fetch = function(url, init, ...args) { if (typeof url === 'string') { - //Check if squad stream. - if (unsafeWindow.location.pathname.includes('/squad')) { - postTwitchWorkerMessage('UpdateIsSquadStream', true); - } else { - postTwitchWorkerMessage('UpdateIsSquadStream', false); - } - if (url.includes('/access_token') || url.includes('gql')) { + if (url.includes('gql')) { //Device ID is used when notifying Twitch of ads. var deviceId = init.headers['X-Device-Id']; if (typeof deviceId !== 'string') { deviceId = init.headers['Device-ID']; } - //Added to prevent eventual UBlock conflicts. - if (typeof deviceId === 'string' && !deviceId.includes('twitch-web-wall-mason')) { + if (typeof deviceId === 'string' && GQLDeviceID != deviceId) { GQLDeviceID = deviceId; - } else if (localDeviceID) { - GQLDeviceID = localDeviceID.replace('"', ''); - GQLDeviceID = GQLDeviceID.replace('"', ''); - } - if (GQLDeviceID) { - if (typeof init.headers['X-Device-Id'] === 'string') { - init.headers['X-Device-Id'] = GQLDeviceID; - } - if (typeof init.headers['Device-ID'] === 'string') { - init.headers['Device-ID'] = GQLDeviceID; - } postTwitchWorkerMessage('UpdateDeviceId', GQLDeviceID); } - //Client version is used in GQL requests. - var clientVersion = init.headers['Client-Version']; - if (clientVersion && typeof clientVersion == 'string') { - ClientVersion = clientVersion; + if (typeof init.headers['Client-Version'] === 'string' && init.headers['Client-Version'] !== ClientVersion) { + postTwitchWorkerMessage('UpdateClientVersion', ClientVersion = init.headers['Client-Version']); } - if (ClientVersion) { - postTwitchWorkerMessage('UpdateClientVersion', ClientVersion); + if (typeof init.headers['Client-Session-Id'] === 'string' && init.headers['Client-Session-Id'] !== ClientSession) { + postTwitchWorkerMessage('UpdateClientSession', ClientSession = init.headers['Client-Session-Id']); } - //Client session is used in GQL requests. - var clientSession = init.headers['Client-Session-Id']; - if (clientSession && typeof clientSession == 'string') { - ClientSession = clientSession; + if (typeof init.headers['Client-Integrity'] === 'string' && init.headers['Client-Integrity'] !== ClientIntegrityHeader) { + postTwitchWorkerMessage('UpdateClientIntegrityHeader', ClientIntegrityHeader = init.headers['Client-Integrity']); } - if (ClientSession) { - postTwitchWorkerMessage('UpdateClientSession', ClientSession); + if (typeof init.headers['Authorization'] === 'string' && init.headers['Authorization'] !== AuthorizationHeader) { + postTwitchWorkerMessage('UpdateAuthorizationHeader', AuthorizationHeader = init.headers['Authorization']); } - //Client ID is used in GQL requests. - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - var clientId = init.headers['Client-ID']; - if (clientId && typeof clientId == 'string') { - ClientID = clientId; + if (ForceAccessTokenPlayerType && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture')) { + 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 { - clientId = init.headers['Client-Id']; - if (clientId && typeof clientId == 'string') { - ClientID = clientId; + if (newBody?.variables?.playerType && newBody?.variables?.playerType !== ForceAccessTokenPlayerType) { + replacedPlayerType = newBody.variables.playerType; + newBody.variables.playerType = ForceAccessTokenPlayerType; } } - if (ClientID) { - postTwitchWorkerMessage('UpdateClientId', ClientID); + if (replacedPlayerType) { + console.log(`Replaced '${replacedPlayerType}' player type with '${ForceAccessTokenPlayerType}' player type`); + init.body = JSON.stringify(newBody); } - //Client integrity header - ClientIntegrityHeader = init.headers['Client-Integrity']; - if (ClientIntegrityHeader) { - postTwitchWorkerMessage('UpdateClientIntegrityHeader', ClientIntegrityHeader); - } - //Authorization header - AuthorizationHeader = init.headers['Authorization']; - if (AuthorizationHeader) { - postTwitchWorkerMessage('UpdateAuthorizationHeader', AuthorizationHeader); - } - } - //To prevent pause/resume loop for mid-rolls. - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && init.body.includes('picture-by-picture')) { - init.body = ''; - } - var isPBYPRequest = url.includes('picture-by-picture'); - if (isPBYPRequest) { - url = ''; } } } @@ -1043,7 +820,7 @@ twitch-videoad.js text/javascript if (videos.length > 0) { if (hidden.apply(document) === true || (webkitHidden && webkitHidden.apply(document) === true)) { wasVideoPlaying = !videos[0].paused && !videos[0].ended; - } else if (wasVideoPlaying && !videos[0].ended) { + } else if (wasVideoPlaying && !videos[0].ended && videos[0].paused && videos[0].muted) { videos[0].play(); } } @@ -1069,15 +846,53 @@ twitch-videoad.js text/javascript }); } }catch{} + // Hooks for preserving volume / resolution + var keysToCache = [ + 'video-quality', + 'video-muted', + 'volume', + 'lowLatencyModeEnabled',// Low Latency + 'persistenceEnabled',// Mini Player + ]; + var cachedValues = new Map(); + for (var i = 0; i < keysToCache.length; i++) { + cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i])); + } + var realSetItem = localStorage.setItem; + localStorage.setItem = function(key, value) { + if (cachedValues.has(key)) { + cachedValues.set(key, value); + } + realSetItem.apply(this, arguments); + }; + var realGetItem = localStorage.getItem; + localStorage.getItem = function(key) { + if (cachedValues.has(key)) { + return cachedValues.get(key); + } + return realGetItem.apply(this, arguments); + }; + if (!localStorage.getItem.toString().includes(Object.keys({cachedValues})[0])) { + // These hooks are useful to preserve player state on player reload + // Firefox doesn't allow hooking of localStorage functions but chrome does + localStorageHookFailed = true; + } } - declareOptions(unsafeWindow); + declareOptions(window); hookWindowWorker(); hookFetch(); if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { onContentLoaded(); } else { - unsafeWindow.addEventListener("DOMContentLoaded", function() { + window.addEventListener("DOMContentLoaded", function() { onContentLoaded(); }); } + window.simulateAds = (depth) => { + if (depth === undefined || depth < 0) { + console.log('Ad depth paramter required (0 = no simulated ad, 1+ = use backup player for given depth)'); + return; + } + postTwitchWorkerMessage('SimulateAds', depth); + }; })(); diff --git a/vaft/vaft.user.js b/vaft/vaft.user.js index 611ee92..7470aa6 100644 --- a/vaft/vaft.user.js +++ b/vaft/vaft.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name TwitchAdSolutions (vaft) // @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 26.0.0 +// @version 27.0.0 // @description Multiple solutions for blocking Twitch ads (vaft) // @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js // @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js @@ -9,45 +9,48 @@ // @match *://*.twitch.tv/* // @run-at document-start // @inject-into page -// @grant GM.xmlHttpRequest -// @connect gql.twitch.tv +// @grant none // ==/UserScript== (function() { 'use strict'; - var ourTwitchAdSolutionsVersion = 11;// Used to prevent conflicts with outdated versions of the scripts - if (typeof unsafeWindow === 'undefined') { - unsafeWindow = window; - } - if (typeof unsafeWindow.twitchAdSolutionsVersion !== 'undefined' && unsafeWindow.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { - console.log("skipping vaft as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + unsafeWindow.twitchAdSolutionsVersion); - unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + var ourTwitchAdSolutionsVersion = 14;// 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; } - unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; function declareOptions(scope) { scope.AdSignifier = 'stitched'; scope.ClientID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - scope.ClientVersion = 'null'; - scope.ClientSession = 'null'; - scope.PlayerType2 = 'embed'; //Source - scope.PlayerType3 = 'site'; //Source - scope.PlayerType4 = 'autoplay'; //360p - scope.CurrentChannelName = null; - scope.UsherParams = null; - scope.WasShowingAd = false; - scope.GQLDeviceID = null; - scope.IsSquadStream = false; + scope.BackupPlayerTypes = [ + 'embed',//Source + 'site',//Source + 'autoplay'//360p + ]; + scope.FallbackPlayerType = 'embed'; + scope.ForceAccessTokenPlayerType = 'site'; + 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; + scope.PlayerReloadLowResTime = 1500; scope.StreamInfos = []; scope.StreamInfosByUrl = []; - scope.MainUrlByUrl = []; - scope.EncodingCacheTimeout = 60000; + scope.GQLDeviceID = null; + scope.ClientVersion = null; + scope.ClientSession = null; scope.ClientIntegrityHeader = null; - scope.AuthorizationHeader = null; + scope.AuthorizationHeader = undefined; + scope.SimulatedAdsDepth = 0; + scope.IsPlayerBuffering = false; + scope.LastPausePlay = 0; + scope.FixPlayerBufferingInsideAds = true; + scope.FixPlayerBufferingOutsideAds = false; + scope.DelayBetweenEachPlayerFixBufferAttempt = 1500; + scope.ActiveStreamInfo = null; } + var localStorageHookFailed = false; var twitchWorkers = []; var adBlockDiv = null; - var OriginalVideoPlayerQuality = null; - var IsPlayerAutoQuality = null; var workerStringConflicts = [ 'twitch', 'isVariantA'// TwitchNoSub @@ -106,8 +109,8 @@ || workerStringReinsert.some((x) => workerString.includes(x)); } function hookWindowWorker() { - var reinsert = getWorkersForReinsert(unsafeWindow.Worker); - var newWorker = class Worker extends getCleanWorker(unsafeWindow.Worker) { + var reinsert = getWorkersForReinsert(window.Worker); + var newWorker = class Worker extends getCleanWorker(window.Worker) { constructor(twitchBlobUrl, options) { var isTwitchWorker = false; try { @@ -120,23 +123,25 @@ var newBlobStr = ` const pendingFetchRequests = new Map(); ${getStreamUrlForResolution.toString()} - ${getStreamForResolution.toString()} - ${stripUnusedParams.toString()} ${processM3U8.toString()} ${hookWorkerFetch.toString()} ${declareOptions.toString()} ${getAccessToken.toString()} ${gqlRequest.toString()} - ${adRecordgqlPacket.toString()} - ${tryNotifyTwitch.toString()} ${parseAttributes.toString()} ${getWasmWorkerJs.toString()} + ${getServerTimeFromM3u8.toString()} + ${replaceServerTimeInM3u8.toString()} + ${tryFixPlayerBuffering.toString()} var 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 == 'UpdateIsSquadStream') { - IsSquadStream = e.data.value; - } else if (e.data.key == 'UpdateClientVersion') { + if (e.data.key == 'UpdateClientVersion') { ClientVersion = e.data.value; } else if (e.data.key == 'UpdateClientSession') { ClientSession = e.data.value; @@ -165,6 +170,20 @@ resolve(response); } } + } else if (e.data.key == 'SimulateAds') { + SimulatedAdsDepth = e.data.value; + console.log('SimulatedAdsDepth:' + SimulatedAdsDepth); + } + if (e.data.funcName) { + if (e.data.funcName == 'onClientSinkBuffering') { + IsPlayerBuffering = true; + } else if (e.data.funcName == 'onClientSinkPlaying') { + IsPlayerBuffering = false; + LastPausePlay = Date.now(); + } else if (e.data.funcName == 'playIntent' || e.data.funcName == 'play') { + LastPausePlay = Date.now(); + } + tryFixPlayerBuffering(); } }); hookWorkerFetch(); @@ -177,105 +196,21 @@ if (adBlockDiv == null) { adBlockDiv = getAdBlockDiv(); } - adBlockDiv.P.textContent = 'Blocking ads'; - adBlockDiv.style.display = 'block'; + if (adBlockDiv != null) { + adBlockDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads'; + adBlockDiv.style.display = 'block'; + } } else if (e.data.key == 'HideAdBlockBanner') { if (adBlockDiv == null) { adBlockDiv = getAdBlockDiv(); } - adBlockDiv.style.display = 'none'; - } else if (e.data.key == 'PauseResumePlayer') { - doTwitchPlayerTask(true, false, false, false, false); - } else if (e.data.key == 'ForceChangeQuality') { - //This is used to fix the bug where the video would freeze. - try { - //if (navigator.userAgent.toLowerCase().indexOf('firefox') == -1) { - return; - //} - var autoQuality = doTwitchPlayerTask(false, false, false, true, false); - var currentQuality = doTwitchPlayerTask(false, true, false, false, false); - if (IsPlayerAutoQuality == null) { - IsPlayerAutoQuality = autoQuality; - } - if (OriginalVideoPlayerQuality == null) { - OriginalVideoPlayerQuality = currentQuality; - } - if (!currentQuality.includes('360') || e.data.value != null) { - if (!OriginalVideoPlayerQuality.includes('360')) { - var settingsMenu = document.querySelector('div[data-a-target="player-settings-menu"]'); - if (settingsMenu == null) { - var settingsCog = document.querySelector('button[data-a-target="player-settings-button"]'); - if (settingsCog) { - settingsCog.click(); - var qualityMenu = document.querySelector('button[data-a-target="player-settings-menu-item-quality"]'); - if (qualityMenu) { - qualityMenu.click(); - } - var lowQuality = document.querySelectorAll('input[data-a-target="tw-radio"'); - if (lowQuality) { - var qualityToSelect = lowQuality.length - 2; - if (e.data.value != null) { - if (e.data.value.includes('original')) { - e.data.value = OriginalVideoPlayerQuality; - if (IsPlayerAutoQuality) { - e.data.value = 'auto'; - } - } - if (e.data.value.includes('160p')) { - qualityToSelect = 5; - } - if (e.data.value.includes('360p')) { - qualityToSelect = 4; - } - if (e.data.value.includes('480p')) { - qualityToSelect = 3; - } - if (e.data.value.includes('720p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('822p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('864p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('900p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('936p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('960p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('1080p')) { - qualityToSelect = 2; - } - if (e.data.value.includes('source')) { - qualityToSelect = 1; - } - if (e.data.value.includes('auto')) { - qualityToSelect = 0; - } - } - var currentQualityLS = unsafeWindow.localStorage.getItem('video-quality'); - lowQuality[qualityToSelect].click(); - settingsCog.click(); - unsafeWindow.localStorage.setItem('video-quality', currentQualityLS); - if (e.data.value != null) { - OriginalVideoPlayerQuality = null; - IsPlayerAutoQuality = null; - doTwitchPlayerTask(false, false, false, true, true); - } - } - } - } - } - } - } catch (err) { - OriginalVideoPlayerQuality = null; - IsPlayerAutoQuality = null; + if (adBlockDiv != null) { + adBlockDiv.style.display = 'none'; } + } else if (e.data.key == 'PauseResumePlayer') { + doTwitchPlayerTask(true, false); + } else if (e.data.key == 'ReloadPlayer') { + doTwitchPlayerTask(false, true); } }); this.addEventListener('message', async event => { @@ -308,7 +243,7 @@ } }; var workerInstance = reinsertWorkers(newWorker, reinsert); - Object.defineProperty(unsafeWindow, 'Worker', { + Object.defineProperty(window, 'Worker', { get: function() { return workerInstance; }, @@ -333,24 +268,12 @@ var realFetch = fetch; fetch = async function(url, options) { if (typeof url === 'string') { + url = url.trimEnd(); if (url.endsWith('m3u8')) { return new Promise(function(resolve, reject) { var processAfter = async function(response) { if (response.status === 200) { - //Here we check the m3u8 for any ads and also try fallback player types if needed. - var responseText = await response.text(); - var weaverText = null; - var fallbackWeaverText = weaverText = await processM3U8(url, responseText, realFetch, PlayerType2); - if (weaverText.includes(AdSignifier)) { - weaverText = await processM3U8(url, responseText, realFetch, PlayerType3); - } - if (weaverText.includes(AdSignifier)) { - weaverText = await processM3U8(url, responseText, realFetch, PlayerType4); - } - if (weaverText.includes(AdSignifier)) { - weaverText = fallbackWeaverText; - } - resolve(new Response(weaverText)); + resolve(new Response(await processM3U8(url, await response.text(), realFetch))); } else { resolve(response); } @@ -364,48 +287,97 @@ }; send(); }); - } else if (url.includes('/api/channel/hls/')) { + } else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - UsherParams = (new URL(url)).search; - CurrentChannelName = channelName; - //To prevent pause/resume loop for mid-rolls. - var isPBYPRequest = url.includes('picture-by-picture'); - if (isPBYPRequest) { - url = ''; + if (ForceAccessTokenPlayerType) { + // parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads + var tempUrl = new URL(url); + tempUrl.searchParams.delete('parent_domains'); + url = tempUrl.toString(); } return new Promise(function(resolve, reject) { var processAfter = async function(response) { if (response.status == 200) { - encodingsM3u8 = await response.text(); + var encodingsM3u8 = await response.text(); + var serverTime = getServerTimeFromM3u8(encodingsM3u8); var streamInfo = StreamInfos[channelName]; - if (streamInfo == null) { - StreamInfos[channelName] = streamInfo = {}; + 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; } - streamInfo.ChannelName = channelName; - streamInfo.RequestedAds = new Set(); - streamInfo.Urls = [];// xxx.m3u8 -> { Resolution: "284x160", FrameRate: 30.0 } - streamInfo.EncodingsM3U8Cache = []; - streamInfo.EncodingsM3U8 = encodingsM3u8; - var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { - streamInfo.Urls[lines[i]] = -1; - if (i > 0 && lines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var attributes = parseAttributes(lines[i - 1]); + if (streamInfo == null || streamInfo.EncodingsM3U8 == null) { + StreamInfos[channelName] = streamInfo = { + ChannelName: channelName, + IsShowingAd: false, + AdStartTime: 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 + }; + var lines = encodingsM3u8.replace('\r', '').split('\n'); + for (var i = 0; i < lines.length - 1; i++) { + if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) { + var attributes = parseAttributes(lines[i]); var resolution = attributes['RESOLUTION']; - var frameRate = attributes['FRAME-RATE']; if (resolution) { - streamInfo.Urls[lines[i]] = { + var resolutionInfo = { Resolution: resolution, - FrameRate: frameRate + 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; + } + } + var 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 (var i = 0; i < lines.length - 1; i++) { + if (lines[i].startsWith('#EXT-X-STREAM-INF')) { + var resSettings = parseAttributes(lines[i].substring(lines[i].indexOf(':') + 1)); + const codecsKey = 'CODECS'; + if (resSettings[codecsKey].startsWith('hev') || resSettings[codecsKey].startsWith('hvc')) { + var oldResolution = resSettings['RESOLUTION']; + const [targetWidth, targetHeight] = oldResolution.split('x').map(Number); + var 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'); + var streamM3u8Url = streamInfo.EncodingsM3U8.match(/^https:.*\.m3u8$/m)[0]; + var streamM3u8Response = await realFetch(streamM3u8Url); + if (streamM3u8Response.status == 200) { + var streamM3u8 = await streamM3u8Response.text(); + if (streamM3u8.includes(AdSignifier) || SimulatedAdsDepth > 0) { + streamInfo.IsUsingModifiedM3U8 = true; + } } } - StreamInfosByUrl[lines[i]] = streamInfo; - MainUrlByUrl[lines[i]] = url; } + } else { + streamInfo.IsUsingModifiedM3U8 = streamInfo.IsShowingAd && streamInfo.ModifiedM3U8; } - resolve(new Response(encodingsM3u8)); + resolve(new Response(replaceServerTimeInM3u8(streamInfo.IsUsingModifiedM3U8 ? streamInfo.ModifiedM3U8 : streamInfo.EncodingsM3U8, serverTime))); } else { resolve(response); } @@ -424,207 +396,185 @@ return realFetch.apply(this, arguments); }; } - function getStreamUrlForResolution(encodingsM3u8, resolutionInfo, qualityOverrideStr) { - var qualityOverride = 0; - if (qualityOverrideStr && qualityOverrideStr.endsWith('p')) { - qualityOverride = qualityOverrideStr.substr(0, qualityOverrideStr.length - 1) | 0; - } - var qualityOverrideFoundQuality = 0; - var qualityOverrideFoundFrameRate = 0; + function getServerTimeFromM3u8(encodingsM3u8) { + var matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"'); + return matches.length > 1 ? matches[1] : null; + } + function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) { + return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8; + } + function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) { var encodingsLines = encodingsM3u8.replace('\r', '').split('\n'); - var firstUrl = null; - var lastUrl = null; + const [targetWidth, targetHeight] = resolutionInfo.Resolution.split('x').map(Number); var matchedResolutionUrl = null; var matchedFrameRate = false; - for (var i = 0; i < encodingsLines.length; i++) { - if (!encodingsLines[i].startsWith('#') && encodingsLines[i].includes('.m3u8')) { - if (i > 0 && encodingsLines[i - 1].startsWith('#EXT-X-STREAM-INF')) { - var attributes = parseAttributes(encodingsLines[i - 1]); - var resolution = attributes['RESOLUTION']; - var frameRate = attributes['FRAME-RATE']; - if (resolution) { - if (qualityOverride) { - var quality = resolution.toLowerCase().split('x')[1]; - if (quality == qualityOverride) { - qualityOverrideFoundQuality = quality; - qualityOverrideFoundFrameRate = frameRate; - matchedResolutionUrl = encodingsLines[i]; - if (frameRate < 40) { - //console.log(`qualityOverride(A) quality:${quality} frameRate:${frameRate}`); - return matchedResolutionUrl; - } - } else if (quality < qualityOverride) { - //if (matchedResolutionUrl) { - // console.log(`qualityOverride(B) quality:${qualityOverrideFoundQuality} frameRate:${qualityOverrideFoundFrameRate}`); - //} else { - // console.log(`qualityOverride(C) quality:${quality} frameRate:${frameRate}`); - //} - return matchedResolutionUrl ? matchedResolutionUrl : encodingsLines[i]; - } - } else if ((!resolutionInfo || resolution == resolutionInfo.Resolution) && - (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) { - matchedResolutionUrl = encodingsLines[i]; - matchedFrameRate = frameRate == resolutionInfo.FrameRate; - if (matchedFrameRate) { - return matchedResolutionUrl; - } + var closestResolutionUrl = null; + var closestResolutionDifference = Infinity; + for (var i = 0; i < encodingsLines.length - 1; i++) { + if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) { + var attributes = parseAttributes(encodingsLines[i]); + var resolution = attributes['RESOLUTION']; + var 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; } } - if (firstUrl == null) { - firstUrl = encodingsLines[i]; + const [width, height] = resolution.split('x').map(Number); + var difference = Math.abs((width * height) - (targetWidth * targetHeight)); + if (difference < closestResolutionDifference) { + closestResolutionUrl = encodingsLines[i + 1]; + closestResolutionDifference = difference; } - lastUrl = encodingsLines[i]; } } } - if (qualityOverride) { - return lastUrl; - } - return matchedResolutionUrl ? matchedResolutionUrl : firstUrl; + return closestResolutionUrl; } - async function getStreamForResolution(streamInfo, resolutionInfo, encodingsM3u8, fallbackStreamStr, playerType, realFetch) { - var qualityOverride = null; - if (streamInfo.EncodingsM3U8Cache[playerType].Resolution != resolutionInfo.Resolution || - streamInfo.EncodingsM3U8Cache[playerType].RequestTime < Date.now() - EncodingCacheTimeout) { - console.log(`Blocking ads (type:${playerType}, resolution:${resolutionInfo.Resolution}, frameRate:${resolutionInfo.FrameRate}, qualityOverride:${qualityOverride})`); - } - streamInfo.EncodingsM3U8Cache[playerType].RequestTime = Date.now(); - streamInfo.EncodingsM3U8Cache[playerType].Value = encodingsM3u8; - streamInfo.EncodingsM3U8Cache[playerType].Resolution = resolutionInfo.Resolution; - var streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, resolutionInfo, qualityOverride); - var streamM3u8Response = await realFetch(streamM3u8Url); - if (streamM3u8Response.status == 200) { - var m3u8Text = await streamM3u8Response.text(); - WasShowingAd = true; + function tryFixPlayerBuffering() { + // NOTE: This "ActiveStreamInfo" variable isn't ideal but now that squad streams are removed it should be correct + if (IsPlayerBuffering && LastPausePlay < Date.now() - DelayBetweenEachPlayerFixBufferAttempt && ActiveStreamInfo && + ((ActiveStreamInfo.IsShowingAd && FixPlayerBufferingInsideAds) || (!ActiveStreamInfo.IsShowingAd && FixPlayerBufferingOutsideAds)) + ) { + console.log("Attempting to fix player buffering by doing pause/play"); + LastPausePlay = Date.now(); postMessage({ - key: 'ShowAdBlockBanner' + key: 'PauseResumePlayer' }); - postMessage({ - key: 'ForceChangeQuality' - }); - if (!m3u8Text || m3u8Text.includes(AdSignifier)) { - streamInfo.EncodingsM3U8Cache[playerType].Value = null; - } - return m3u8Text; - } else { - streamInfo.EncodingsM3U8Cache[playerType].Value = null; - return fallbackStreamStr; } } - function stripUnusedParams(str, params) { - if (!params) { - params = [ 'token', 'sig' ]; - } - var tempUrl = new URL('https://localhost/' + str); - for (var i = 0; i < params.length; i++) { - tempUrl.searchParams.delete(params[i]); - } - return tempUrl.pathname.substring(1) + tempUrl.search; - } - async function processM3U8(url, textStr, realFetch, playerType) { - //Checks the m3u8 for ads and if it finds one, instead returns an ad-free stream. + async function processM3U8(url, textStr, realFetch) { var streamInfo = StreamInfosByUrl[url]; - //Ad blocking for squad streams is disabled due to the way multiple weaver urls are used. No workaround so far. - if (IsSquadStream == true) { + if (!streamInfo) { return textStr; } - if (!textStr) { - return textStr; - } - //Some live streams use mp4. - if (!textStr.includes('.ts') && !textStr.includes('.mp4')) { - return textStr; - } - var haveAdTags = textStr.includes(AdSignifier); + ActiveStreamInfo = streamInfo; + var haveAdTags = textStr.includes(AdSignifier) || SimulatedAdsDepth > 0; if (haveAdTags) { - var isMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); - //Reduces ad frequency. TODO: Reduce the number of requests. This is really spamming Twitch with requests. - if (!isMidroll) { - if (playerType === PlayerType2) { - var lines = textStr.replace('\r', '').split('\n'); - for (var i = 0; i < lines.length; i++) { - var 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; - } + streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); + if (!streamInfo.IsMidroll) { + var lines = textStr.replace('\r', '').split('\n'); + for (var i = 0; i < lines.length; i++) { + var 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; } } } - try { - //tryNotifyTwitch(textStr); - } catch (err) {} } - var currentResolution = null; - if (streamInfo && streamInfo.Urls) { - for (const [resUrl, resInfo] of Object.entries(streamInfo.Urls)) { - if (resUrl == url) { - currentResolution = resInfo; - //console.log(resInfo.Resolution); + streamInfo.Resolution + var currentResolution = streamInfo.Urls[url]; + if (!currentResolution) { + console.log('Ads will leak due to missing resolution info for ' + url); + return textStr; + } + if (streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) { + streamInfo.AdStartTime = Date.now(); + } + if (!streamInfo.IsShowingAd) { + streamInfo.AdStartTime = Date.now(); + streamInfo.IsShowingAd = true; + if (streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) { + postMessage({ + key: 'ReloadPlayer' + }); + } + postMessage({ + key: 'ShowAdBlockBanner', + isMidroll: streamInfo.IsMidroll + }); + } + var backupPlayerType = null; + var backupM3u8 = null; + var fallbackM3u8 = null; + var startIndex = 0; + if ((streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) || + (streamInfo.ModifiedM3U8 && streamInfo.AdStartTime > Date.now() - PlayerReloadLowResTime) + ) { + // When doing player reload there are a lot of requests which causes the backup stream to load in slow. Briefly prefer using the low res version to prevent long delays + startIndex = BackupPlayerTypes.length - 1; + } + for (var playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < BackupPlayerTypes.length; playerTypeIndex++) { + var playerType = BackupPlayerTypes[playerTypeIndex]; + for (var 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) + var isFreshM3u8 = false; + var encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType]; + if (!encodingsM3u8) { + isFreshM3u8 = true; + try { + var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, playerType); + if (accessTokenResponse.status === 200) { + var accessToken = await accessTokenResponse.json(); + var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams); + urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); + urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); + var encodingsM3u8Response = await realFetch(urlInfo.href); + if (encodingsM3u8Response.status === 200) { + encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType] = await encodingsM3u8Response.text(); + } + } + } catch (err) {} + } + if (encodingsM3u8) { + try { + var streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, currentResolution); + var streamM3u8Response = await realFetch(streamM3u8Url); + if (streamM3u8Response.status == 200) { + var m3u8Text = await streamM3u8Response.text(); + if (m3u8Text) { + if (playerType == FallbackPlayerType) { + fallbackM3u8 = m3u8Text; + } + if (!m3u8Text.includes(AdSignifier) && (SimulatedAdsDepth == 0 || playerTypeIndex >= SimulatedAdsDepth - 1)) { + backupPlayerType = playerType; + backupM3u8 = m3u8Text; + break; + } + } + } + } catch (err) {} + } + if (startIndex != 0) { + break; + } + streamInfo.BackupEncodingsM3U8Cache[playerType] = null; + if (isFreshM3u8) { break; } } } - // Keep the m3u8 around for a little while (once per ad) before requesting a new one - var encodingsM3U8Cache = streamInfo.EncodingsM3U8Cache[playerType]; - if (encodingsM3U8Cache) { - if (encodingsM3U8Cache.Value && encodingsM3U8Cache.RequestTime >= Date.now() - EncodingCacheTimeout) { - try { - var result = getStreamForResolution(streamInfo, currentResolution, encodingsM3U8Cache.Value, null, playerType, realFetch); - if (result) { - return result; - } - } catch (err) { - encodingsM3U8Cache.Value = null; - } + 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})`); } - } else { - streamInfo.EncodingsM3U8Cache[playerType] = { - RequestTime: Date.now(), - Value: null, - Resolution: null - }; } - var accessTokenResponse = await getAccessToken(CurrentChannelName, playerType); - if (accessTokenResponse.status === 200) { - var accessToken = await accessTokenResponse.json(); - try { - var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + CurrentChannelName + '.m3u8' + UsherParams); - urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); - urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); - var encodingsM3u8Response = await realFetch(urlInfo.href); - if (encodingsM3u8Response.status === 200) { - return getStreamForResolution(streamInfo, currentResolution, await encodingsM3u8Response.text(), textStr, playerType, realFetch); - } else { - return textStr; - } - } catch (err) {} - return textStr; - } else { - return textStr; - } - } else { - if (WasShowingAd) { - console.log('Finished blocking ads'); - WasShowingAd = false; - //Here we put player back to original quality and remove the blocking message. - postMessage({ - key: 'ForceChangeQuality', - value: 'original' - }); - postMessage({ - key: 'PauseResumePlayer' - }); - postMessage({ - key: 'HideAdBlockBanner' - }); - } - return textStr; + } else if (streamInfo.IsShowingAd) { + console.log('Finished blocking ads'); + streamInfo.IsShowingAd = false; + streamInfo.ActiveBackupPlayerType = null; + postMessage({ + key: streamInfo.IsUsingModifiedM3U8 ? 'ReloadPlayer' : 'PauseResumePlayer' + }); + postMessage({ + key: 'HideAdBlockBanner' + }); } + tryFixPlayerBuffering(); return textStr; } function parseAttributes(str) { @@ -639,69 +589,6 @@ return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num]; })); } - async function tryNotifyTwitch(streamM3u8) { - //We notify that an ad was requested but was not visible and was also muted. - var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); - if (matches.length > 1) { - const attrString = matches[1]; - const attr = parseAttributes(attrString); - var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); - var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); - var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; - var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; - var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; - var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; - var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; - var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); - const baseData = { - stitched: true, - roll_type: rollType, - player_mute: true, - player_volume: 0.0, - visible: false, - }; - for (let podPosition = 0; podPosition < podLength; podPosition++) { - const extendedData = { - ...baseData, - ad_id: adId, - ad_position: podPosition, - duration: 0, - creative_id: creativeId, - total_ads: podLength, - order_id: orderId, - line_item_id: lineItemId, - }; - await gqlRequest(adRecordgqlPacket('video_ad_impression', radToken, extendedData)); - for (let quartile = 0; quartile < 4; quartile++) { - await gqlRequest( - adRecordgqlPacket('video_ad_quartile_complete', radToken, { - ...extendedData, - quartile: quartile + 1, - }) - ); - } - await gqlRequest(adRecordgqlPacket('video_ad_pod_complete', radToken, baseData)); - } - } - } - function adRecordgqlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; - } function getAccessToken(channelName, playerType) { var body = null; var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "android", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "android", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}'; @@ -728,12 +615,12 @@ } var headers = { 'Client-ID': ClientID, - 'Client-Integrity': ClientIntegrityHeader, 'Device-ID': GQLDeviceID, 'X-Device-Id': GQLDeviceID, - 'Client-Version': ClientVersion, - 'Client-Session-Id': ClientSession, - 'Authorization': AuthorizationHeader + '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); @@ -756,180 +643,103 @@ }); }); } - function doTwitchPlayerTask(isPausePlay, isCheckQuality, isCorrectBuffer, isAutoQuality, setAutoQuality) { - //This will do an instant pause/play to return to original quality once the ad is finished. - //We also use this function to get the current video player quality set by the user. - //We also use this function to quickly pause/play the player when switching tabs to stop delays. - try { - var videoController = null; - var videoPlayer = null; - 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 doTwitchPlayerTask(isPausePlay, isReload) { + function findReactNode(root, constraint) { + if (root.stateNode && constraint(root.stateNode)) { + return root.stateNode; } - function findReactRootNode() { - var reactRootNode = null; - var rootNode = document.querySelector('#root'); - if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { - reactRootNode = rootNode._reactRootContainer._internalRoot.current; + let node = root.child; + while (node) { + const result = findReactNode(node, constraint); + if (result) { + return result; } - if (reactRootNode == null) { - var containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer')); - if (containerName != null) { - reactRootNode = rootNode[containerName]; - } - } - return reactRootNode; + node = node.sibling; } - var reactRootNode = findReactRootNode(); - if (!reactRootNode) { - console.log('Could not find react root'); - return; + return null; + } + function findReactRootNode() { + var reactRootNode = null; + var rootNode = document.querySelector('#root'); + if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { + reactRootNode = rootNode._reactRootContainer._internalRoot.current; } - videoPlayer = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); - videoPlayer = videoPlayer && videoPlayer.props && videoPlayer.props.mediaPlayerInstance ? videoPlayer.props.mediaPlayerInstance : null; - if (isPausePlay) { - videoPlayer.pause(); - videoPlayer.play(); - return; - } - if (isCheckQuality) { - if (typeof videoPlayer.getQuality() == 'undefined') { - return; - } - var playerQuality = JSON.stringify(videoPlayer.getQuality()); - if (playerQuality) { - return playerQuality; - } else { - return; + if (reactRootNode == null) { + var containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer')); + if (containerName != null) { + reactRootNode = rootNode[containerName]; } } - if (isAutoQuality) { - if (typeof videoPlayer.isAutoQualityMode() == 'undefined') { - return false; - } - var autoQuality = videoPlayer.isAutoQualityMode(); - if (autoQuality) { - videoPlayer.setAutoQualityMode(false); - return autoQuality; - } else { - return false; - } + return reactRootNode; + } + var reactRootNode = findReactRootNode(); + if (!reactRootNode) { + console.log('Could not find react root'); + return; + } + var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); + player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; + var playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); + if (!player) { + console.log('Could not find player'); + return; + } + if (!playerState) { + console.log('Could not find player state'); + return; + } + if (player.paused || player.core?.paused) { + return; + } + if (isPausePlay) { + player.pause(); + player.play(); + return; + } + if (isReload) { + const lsKeyQuality = 'video-quality'; + const lsKeyMuted = 'video-muted'; + const lsKeyVolume = 'volume'; + var currentQualityLS = localStorage.getItem(lsKeyQuality); + var currentMutedLS = localStorage.getItem(lsKeyMuted); + var currentVolumeLS = localStorage.getItem(lsKeyVolume); + if (localStorageHookFailed && player?.core?.state) { + localStorage.setItem(lsKeyMuted, JSON.stringify({default:player.core.state.muted})); + localStorage.setItem(lsKeyVolume, player.core.state.volume); } - if (setAutoQuality) { - videoPlayer.setAutoQualityMode(true); - return; + if (localStorageHookFailed && player?.core?.state?.quality?.group) { + localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group})); } - //This only happens when switching tabs and is to correct the high latency caused when opening background tabs and going to them at a later time. - //We check that this is a live stream by the page URL, to prevent vod/clip pause/plays. - try { - var currentPageURL = document.URL; - var isLive = true; - if (currentPageURL.includes('videos/') || currentPageURL.includes('clip/')) { - isLive = false; - } - if (isCorrectBuffer && isLive) { - //A timer is needed due to the player not resuming without it. - setTimeout(function() { - //If latency to broadcaster is above 5 or 15 seconds upon switching tabs, we pause and play the player to reset the latency. - //If latency is between 0-6, user can manually pause and resume to reset latency further. - if (videoPlayer.isLiveLowLatency() && videoPlayer.getLiveLatency() > 5) { - videoPlayer.pause(); - videoPlayer.play(); - } else if (videoPlayer.getLiveLatency() > 15) { - videoPlayer.pause(); - videoPlayer.play(); - } - }, 3000); - } - } catch (err) {} - } catch (err) {} + playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true }); + player.play(); + if (localStorageHookFailed) { + setTimeout(() => { + localStorage.setItem(lsKeyQuality, currentQualityLS); + localStorage.setItem(lsKeyMuted, currentMutedLS); + localStorage.setItem(lsKeyVolume, currentVolumeLS); + }, 3000); + } + return; + } } - unsafeWindow.reloadTwitchPlayer = doTwitchPlayerTask; - var localDeviceID = null; - localDeviceID = unsafeWindow.localStorage.getItem('local_copy_unique_id'); + window.reloadTwitchPlayer = doTwitchPlayerTask; function postTwitchWorkerMessage(key, value) { twitchWorkers.forEach((worker) => { worker.postMessage({key: key, value: value}); }); } - function makeGmXmlHttpRequest(fetchRequest) { - return new Promise((resolve, reject) => { - GM.xmlHttpRequest({ - method: fetchRequest.options.method, - url: fetchRequest.url, - data: fetchRequest.options.body, - headers: fetchRequest.options.headers, - onload: response => resolve(response), - onerror: error => reject(error) - }); - }); - } - // Taken from https://github.com/dimdenGD/YeahTwitter/blob/9e0520f5abe029f57929795d8de0d2e5d3751cf3/us.js#L48 - function parseHeaders(headersString) { - const headers = new Headers(); - const lines = headersString.trim().split(/[\r\n]+/); - lines.forEach(line => { - const parts = line.split(':'); - const header = parts.shift(); - const value = parts.join(':'); - headers.append(header, value); - }); - return headers; - } - var serverLikesThisBrowser = false; - var serverHatesThisBrowser = false; async function handleWorkerFetchRequest(fetchRequest) { try { - if (serverLikesThisBrowser || !serverHatesThisBrowser) { - const response = await unsafeWindow.realFetch(fetchRequest.url, fetchRequest.options); - const responseBody = await response.text(); - const responseObject = { - id: fetchRequest.id, - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()), - body: responseBody - }; - if (responseObject.status === 200) { - var resp = JSON.parse(responseBody); - if (typeof resp.errors !== 'undefined') { - serverHatesThisBrowser = true; - } else { - serverLikesThisBrowser = true; - } - } - if (serverLikesThisBrowser || !serverHatesThisBrowser) { - return responseObject; - } - } - if (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest !== 'undefined') { - fetchRequest.options.headers['Sec-Ch-Ua'] = '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"'; - fetchRequest.options.headers['Referer'] = 'https://www.twitch.tv/'; - fetchRequest.options.headers['Origin'] = 'https://www.twitch.tv/'; - fetchRequest.options.headers['Host'] = 'gql.twitch.tv'; - const response = await makeGmXmlHttpRequest(fetchRequest); - const responseBody = response.responseText; - const responseObject = { - id: fetchRequest.id, - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(parseHeaders(response.responseHeaders).entries()), - body: responseBody - }; - return responseObject; - } - throw { message: 'Failed to resolve GQL request. Try the userscript version of the ad blocking solution' }; + const response = await window.realFetch(fetchRequest.url, fetchRequest.options); + const responseBody = await response.text(); + const responseObject = { + id: fetchRequest.id, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseBody + }; + return responseObject; } catch (error) { return { id: fetchRequest.id, @@ -938,86 +748,52 @@ } } function hookFetch() { - var realFetch = unsafeWindow.fetch; - unsafeWindow.realFetch = realFetch; - unsafeWindow.fetch = function(url, init, ...args) { + var realFetch = window.fetch; + window.realFetch = realFetch; + window.fetch = function(url, init, ...args) { if (typeof url === 'string') { - //Check if squad stream. - if (unsafeWindow.location.pathname.includes('/squad')) { - postTwitchWorkerMessage('UpdateIsSquadStream', true); - } else { - postTwitchWorkerMessage('UpdateIsSquadStream', false); - } - if (url.includes('/access_token') || url.includes('gql')) { + if (url.includes('gql')) { //Device ID is used when notifying Twitch of ads. var deviceId = init.headers['X-Device-Id']; if (typeof deviceId !== 'string') { deviceId = init.headers['Device-ID']; } - //Added to prevent eventual UBlock conflicts. - if (typeof deviceId === 'string' && !deviceId.includes('twitch-web-wall-mason')) { + if (typeof deviceId === 'string' && GQLDeviceID != deviceId) { GQLDeviceID = deviceId; - } else if (localDeviceID) { - GQLDeviceID = localDeviceID.replace('"', ''); - GQLDeviceID = GQLDeviceID.replace('"', ''); - } - if (GQLDeviceID) { - if (typeof init.headers['X-Device-Id'] === 'string') { - init.headers['X-Device-Id'] = GQLDeviceID; - } - if (typeof init.headers['Device-ID'] === 'string') { - init.headers['Device-ID'] = GQLDeviceID; - } postTwitchWorkerMessage('UpdateDeviceId', GQLDeviceID); } - //Client version is used in GQL requests. - var clientVersion = init.headers['Client-Version']; - if (clientVersion && typeof clientVersion == 'string') { - ClientVersion = clientVersion; + if (typeof init.headers['Client-Version'] === 'string' && init.headers['Client-Version'] !== ClientVersion) { + postTwitchWorkerMessage('UpdateClientVersion', ClientVersion = init.headers['Client-Version']); } - if (ClientVersion) { - postTwitchWorkerMessage('UpdateClientVersion', ClientVersion); + if (typeof init.headers['Client-Session-Id'] === 'string' && init.headers['Client-Session-Id'] !== ClientSession) { + postTwitchWorkerMessage('UpdateClientSession', ClientSession = init.headers['Client-Session-Id']); } - //Client session is used in GQL requests. - var clientSession = init.headers['Client-Session-Id']; - if (clientSession && typeof clientSession == 'string') { - ClientSession = clientSession; + if (typeof init.headers['Client-Integrity'] === 'string' && init.headers['Client-Integrity'] !== ClientIntegrityHeader) { + postTwitchWorkerMessage('UpdateClientIntegrityHeader', ClientIntegrityHeader = init.headers['Client-Integrity']); } - if (ClientSession) { - postTwitchWorkerMessage('UpdateClientSession', ClientSession); + if (typeof init.headers['Authorization'] === 'string' && init.headers['Authorization'] !== AuthorizationHeader) { + postTwitchWorkerMessage('UpdateAuthorizationHeader', AuthorizationHeader = init.headers['Authorization']); } - //Client ID is used in GQL requests. - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { - var clientId = init.headers['Client-ID']; - if (clientId && typeof clientId == 'string') { - ClientID = clientId; + if (ForceAccessTokenPlayerType && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture')) { + 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 { - clientId = init.headers['Client-Id']; - if (clientId && typeof clientId == 'string') { - ClientID = clientId; + if (newBody?.variables?.playerType && newBody?.variables?.playerType !== ForceAccessTokenPlayerType) { + replacedPlayerType = newBody.variables.playerType; + newBody.variables.playerType = ForceAccessTokenPlayerType; } } - if (ClientID) { - postTwitchWorkerMessage('UpdateClientId', ClientID); + if (replacedPlayerType) { + console.log(`Replaced '${replacedPlayerType}' player type with '${ForceAccessTokenPlayerType}' player type`); + init.body = JSON.stringify(newBody); } - //Client integrity header - ClientIntegrityHeader = init.headers['Client-Integrity']; - if (ClientIntegrityHeader) { - postTwitchWorkerMessage('UpdateClientIntegrityHeader', ClientIntegrityHeader); - } - //Authorization header - AuthorizationHeader = init.headers['Authorization']; - if (AuthorizationHeader) { - postTwitchWorkerMessage('UpdateAuthorizationHeader', AuthorizationHeader); - } - } - //To prevent pause/resume loop for mid-rolls. - if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && init.body.includes('picture-by-picture')) { - init.body = ''; - } - var isPBYPRequest = url.includes('picture-by-picture'); - if (isPBYPRequest) { - url = ''; } } } @@ -1055,7 +831,7 @@ if (videos.length > 0) { if (hidden.apply(document) === true || (webkitHidden && webkitHidden.apply(document) === true)) { wasVideoPlaying = !videos[0].paused && !videos[0].ended; - } else if (wasVideoPlaying && !videos[0].ended) { + } else if (wasVideoPlaying && !videos[0].ended && videos[0].paused && videos[0].muted) { videos[0].play(); } } @@ -1081,15 +857,53 @@ }); } }catch{} + // Hooks for preserving volume / resolution + var keysToCache = [ + 'video-quality', + 'video-muted', + 'volume', + 'lowLatencyModeEnabled',// Low Latency + 'persistenceEnabled',// Mini Player + ]; + var cachedValues = new Map(); + for (var i = 0; i < keysToCache.length; i++) { + cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i])); + } + var realSetItem = localStorage.setItem; + localStorage.setItem = function(key, value) { + if (cachedValues.has(key)) { + cachedValues.set(key, value); + } + realSetItem.apply(this, arguments); + }; + var realGetItem = localStorage.getItem; + localStorage.getItem = function(key) { + if (cachedValues.has(key)) { + return cachedValues.get(key); + } + return realGetItem.apply(this, arguments); + }; + if (!localStorage.getItem.toString().includes(Object.keys({cachedValues})[0])) { + // These hooks are useful to preserve player state on player reload + // Firefox doesn't allow hooking of localStorage functions but chrome does + localStorageHookFailed = true; + } } - declareOptions(unsafeWindow); + declareOptions(window); hookWindowWorker(); hookFetch(); if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { onContentLoaded(); } else { - unsafeWindow.addEventListener("DOMContentLoaded", function() { + window.addEventListener("DOMContentLoaded", function() { onContentLoaded(); }); } + window.simulateAds = (depth) => { + if (depth === undefined || depth < 0) { + console.log('Ad depth paramter required (0 = no simulated ad, 1+ = use backup player for given depth)'); + return; + } + postTwitchWorkerMessage('SimulateAds', depth); + }; })(); diff --git a/video-swap-new/video-swap-new-ublock-origin.js b/video-swap-new/video-swap-new-ublock-origin.js index e2c97f1..de49e26 100644 --- a/video-swap-new/video-swap-new-ublock-origin.js +++ b/video-swap-new/video-swap-new-ublock-origin.js @@ -1,37 +1,32 @@ twitch-videoad.js text/javascript (function() { if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - var ourTwitchAdSolutionsVersion = 13;// Used to prevent conflicts with outdated versions of the scripts - if (typeof unsafeWindow === 'undefined') { - unsafeWindow = window; - } - if (typeof unsafeWindow.twitchAdSolutionsVersion !== 'undefined' && unsafeWindow.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { - console.log("skipping video-swap-new as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + unsafeWindow.twitchAdSolutionsVersion); - unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + var ourTwitchAdSolutionsVersion = 14;// Used to prevent conflicts with outdated versions of the scripts + if (typeof window.twitchAdSolutionsVersion !== 'undefined' && window.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { + console.log("skipping video-swap-new as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + window.twitchAdSolutionsVersion); + window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; return; } - unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; function declareOptions(scope) { // Options / globals - scope.OPT_PREROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ]; - scope.OPT_MIDROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ]; + scope.OPT_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ]; scope.OPT_BACKUP_PLATFORM = 'android'; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site'; - scope.OPT_STRIP_PARENT_DOMAINS = true; - scope.OPT_SHOW_AD_BANNER = true; + scope.OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE = 'site'; scope.AD_SIGNIFIER = 'stitched-ad'; scope.LIVE_SIGNIFIER = ',live'; scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; // These are only really for Worker scope... scope.StreamInfos = []; scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; // Need this in both scopes. Window scope needs to update this to worker scope. scope.gql_device_id = null; scope.ClientIntegrityHeader = null; - scope.AuthorizationHeader = null; + scope.AuthorizationHeader = undefined; scope.SimulatedAdsDepth = 0; } + var localStorageHookFailed = false; + var adBlockDiv = null; var twitchWorkers = []; var workerStringConflicts = [ 'twitch', @@ -91,8 +86,8 @@ twitch-videoad.js text/javascript || workerStringReinsert.some((x) => workerString.includes(x)); } function hookWindowWorker() { - var reinsert = getWorkersForReinsert(unsafeWindow.Worker); - var newWorker = class Worker extends getCleanWorker(unsafeWindow.Worker) { + var reinsert = getWorkersForReinsert(window.Worker); + var newWorker = class Worker extends getCleanWorker(window.Worker) { constructor(twitchBlobUrl, options) { var isTwitchWorker = false; try { @@ -109,18 +104,18 @@ twitch-videoad.js text/javascript ${declareOptions.toString()} ${getAccessToken.toString()} ${gqlRequest.toString()} - ${makeGraphQlPacket.toString()} ${parseAttributes.toString()} ${setStreamInfoUrls.toString()} ${onFoundAd.toString()} ${getWasmWorkerJs.toString()} ${getServerTimeFromM3u8.toString()} ${replaceServerTimeInM3u8.toString()} + ${getStreamUrlForResolution.toString()} var workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}'); declareOptions(self); gql_device_id = ${gql_device_id ? "'" + gql_device_id + "'" : null}; - AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : null}; - ClientIntegrityHeader = ${AuthorizationHeader ? "'" + ClientIntegrityHeader + "'" : null}; + AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined}; + ClientIntegrityHeader = ${ClientIntegrityHeader ? "'" + ClientIntegrityHeader + "'" : null}; self.addEventListener('message', function(e) { if (e.data.key == 'UboUpdateDeviceId') { gql_device_id = e.data.value; @@ -158,25 +153,23 @@ twitch-videoad.js text/javascript this.addEventListener('message', (e) => { // NOTE: Removed adDiv caching as '.video-player' can change between streams? if (e.data.key == 'UboShowAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads'; - if (OPT_SHOW_AD_BANNER) { - adDiv.style.display = 'block'; - } + if (adBlockDiv == null) { + adBlockDiv = getAdDiv(); + } + if (adBlockDiv != null) { + adBlockDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads'; + adBlockDiv.style.display = 'block'; } } else if (e.data.key == 'UboHideAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.style.display = 'none'; + if (adBlockDiv == null) { + adBlockDiv = getAdDiv(); + } + if (adBlockDiv != null) { + adBlockDiv.style.display = 'none'; } - } else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); } else if (e.data.key == 'UboReloadPlayer') { reloadTwitchPlayer(); } else if (e.data.key == 'UboPauseResumePlayer') { - reloadTwitchPlayer(false, true); - } else if (e.data.key == 'UboSeekPlayer') { reloadTwitchPlayer(true); } }); @@ -209,7 +202,7 @@ twitch-videoad.js text/javascript } } var workerInstance = reinsertWorkers(newWorker, reinsert); - Object.defineProperty(unsafeWindow, 'Worker', { + Object.defineProperty(window, 'Worker', { get: function() { return workerInstance; }, @@ -231,22 +224,33 @@ twitch-videoad.js text/javascript } function setStreamInfoUrls(streamInfo, encodingsM3u8) { var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var j = 0; j < lines.length; j++) { - if (!lines[j].startsWith('#') && lines[j].includes('.m3u8')) { - StreamInfosByUrl[lines[j].trimEnd()] = streamInfo; + for (var i = 0; i < lines.length; i++) { + if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { + StreamInfosByUrl[lines[i].trimEnd()] = streamInfo; + } + if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) { + var attributes = parseAttributes(lines[i]); + var resolution = attributes['RESOLUTION']; + if (resolution) { + var resolutionInfo = { + Resolution: resolution, + FrameRate: attributes['FRAME-RATE'], + Url: lines[i + 1] + }; + streamInfo.Urls.set(lines[i + 1].trimEnd(), resolutionInfo); + } } } } - async function onFoundAd(streamInfo, textStr, reloadPlayer, realFetch, url) { + async function onFoundAd(streamInfo, textStr, reloadPlayer, realFetch, url, resolutionInfo) { var result = textStr; streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); - var playerTypes = streamInfo.IsMidroll ? OPT_MIDROLL_BACKUP_PLAYER_TYPES : OPT_PREROLL_BACKUP_PLAYER_TYPES; + var playerTypes = OPT_BACKUP_PLAYER_TYPES; if (streamInfo.BackupEncodingsStatus.size >= playerTypes.length) { return textStr; } if (streamInfo.BackupEncodings && !streamInfo.BackupEncodings.includes(url)) { - // NOTE: This might cause some freezing issues - var streamM3u8Url = streamInfo.BackupEncodings.match(/^https:.*\.m3u8.*$/m)[0]; + var streamM3u8Url = getStreamUrlForResolution(streamInfo.BackupEncodings, resolutionInfo); var streamM3u8Response = await realFetch(streamM3u8Url); if (streamM3u8Response.status === 200) { return await streamM3u8Response.text(); @@ -266,7 +270,7 @@ twitch-videoad.js text/javascript var encodingsM3u8Response = await realFetch(urlInfo.href); if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) { var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8.*$/m)[0]; + var streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, resolutionInfo); var streamM3u8Response = await realFetch(streamM3u8Url); if (streamM3u8Response.status === 200) { var backTextStr = await streamM3u8Response.text(); @@ -275,48 +279,31 @@ twitch-videoad.js text/javascript backupPlayerTypeInfo = ' (' + playerType + ')'; streamInfo.BackupEncodingsStatus.set(playerType, 1); streamInfo.BackupEncodingsPlayerTypeIndex = i; - if (playerType !== 'embed') { - // Low resolution streams will reduce the number of resolutions in the UI. To fix this we merge the highest low res into the main m3u8 - // TODO: Do a better matching up of the resolutions rather than picking the highest low res for all - var lowResLines = encodingsM3u8.replace('\r', '').split('\n'); - var lowResBestUrl = null; - var lowResSettings = []; - for (var j = 0; j < lowResLines.length; j++) { - if (lowResLines[j].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(lowResLines[j])['RESOLUTION']; - if (res && lowResLines[j + 1].endsWith('.m3u8')) { - // Assumes resolutions are correctly ordered - lowResBestUrl = lowResLines[j + 1]; - lowResSettings = parseAttributes(lowResLines[j].substring(lowResLines[j].indexOf(':') + 1)); - break; + if (playerType !== 'embed' && streamInfo.Encodings != null) { + // Low resolution streams will reduce the number of resolutions in the UI. To fix this we merge the low res URLs into the main m3u8 + var normalEncodingsM3u8 = streamInfo.Encodings; + var normalLines = normalEncodingsM3u8.replace('\r', '').split('\n'); + for (var j = 0; j < normalLines.length - 1; j++) { + if (normalLines[j].startsWith('#EXT-X-STREAM-INF')) { + var resSettings = parseAttributes(normalLines[j].substring(normalLines[j].indexOf(':') + 1)); + var lowResUrl = getStreamUrlForResolution(encodingsM3u8, streamInfo.Urls.get(normalLines[j + 1].trimEnd())); + var lowResInf = encodingsM3u8.match(new RegExp(`^.*(?=\n.*${lowResUrl})`, 'm'))[0]; + var lowResSettings = parseAttributes(lowResInf.substring(lowResInf.indexOf(':') + 1)); + //console.log('map ' + resSettings['RESOLUTION'] + ' to ' + lowResSettings['RESOLUTION']); + const codecsKey = 'CODECS'; + if (typeof resSettings[codecsKey] === 'string' && typeof lowResSettings[codecsKey] === 'string' && + resSettings[codecsKey].length >= 3 && lowResSettings[codecsKey].length >= 3 && + (resSettings[codecsKey].startsWith('hev') || resSettings[codecsKey].startsWith('hvc')) && + resSettings[codecsKey].substring(0, 3) !== lowResSettings[codecsKey].substring(0, 3) + ) { + console.log('swap ' + resSettings[codecsKey] + ' to ' + lowResSettings[codecsKey]); + normalLines[j] = normalLines[j].replace(/CODECS="[^"]+"/, `CODECS="${lowResSettings[codecsKey]}"`); + console.log(normalLines[j]); } + normalLines[j + 1] = lowResUrl + ' '.repeat(j + 1);// The stream doesn't load unless each url line is unique } } - if (lowResBestUrl != null && streamInfo.Encodings != null) { - var normalEncodingsM3u8 = streamInfo.Encodings; - var normalLines = normalEncodingsM3u8.replace('\r', '').split('\n'); - for (var j = 0; j < normalLines.length - 1; j++) { - if (normalLines[j].startsWith('#EXT-X-STREAM-INF')) { - var resSettings = parseAttributes(normalLines[j].substring(normalLines[j].indexOf(':') + 1)); - const codecsKey = 'CODECS'; - if (typeof resSettings[codecsKey] === 'string' && typeof lowResSettings[codecsKey] === 'string' && - resSettings[codecsKey].length >= 3 && lowResSettings[codecsKey].length >= 3 && - resSettings[codecsKey].substring(0, 3) === 'hev' && - resSettings[codecsKey].substring(0, 3) !== lowResSettings[codecsKey].substring(0, 3) - ) { - console.log('swap ' + resSettings[codecsKey] + ' to ' + lowResSettings[codecsKey]); - normalLines[j] = normalLines[j].replace(/CODECS="[^"]+"/, `CODECS="${lowResSettings[codecsKey]}"`); - console.log(normalLines[j]); - } - var res = parseAttributes(normalLines[j])['RESOLUTION']; - if (res) { - lowResBestUrl += ' ';// The stream doesn't load unless each url line is unique - normalLines[j + 1] = lowResBestUrl; - } - } - } - encodingsM3u8 = normalLines.join('\r\n'); - } + encodingsM3u8 = normalLines.join('\n'); } streamInfo.BackupEncodings = encodingsM3u8; setStreamInfoUrls(streamInfo, encodingsM3u8); @@ -346,6 +333,10 @@ twitch-videoad.js text/javascript //postMessage({key:'UboHideAdBanner'}); return textStr; } + var currentResolution = streamInfo.Urls.get(url); + if (!currentResolution) { + return textStr; + } var haveAdTags = textStr.includes(AD_SIGNIFIER); if (SimulatedAdsDepth > 0 && (!streamInfo.BackupEncodings || !streamInfo.BackupEncodings.includes(url) || SimulatedAdsDepth - 1 > streamInfo.BackupEncodingsPlayerTypeIndex)) { haveAdTags = true; @@ -382,10 +373,10 @@ twitch-videoad.js text/javascript } } if (streamInfo.BackupEncodings && haveAdTags) { - textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url); + textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url, currentResolution); } } else if (haveAdTags && !streamInfo.IsMovingOffBackupEncodings) { - textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url); + textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url, currentResolution); } else { postMessage({key:'UboHideAdBanner'}); } @@ -424,14 +415,7 @@ twitch-videoad.js text/javascript } else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_STRIP_PARENT_DOMAINS) { + if (OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE) { // parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads var tempUrl = new URL(url); tempUrl.searchParams.delete('parent_domains'); @@ -457,7 +441,8 @@ twitch-videoad.js text/javascript IsMidroll: false, UseFallbackStream: false, ChannelName: channelName, - UsherParams: (new URL(url)).search + UsherParams: (new URL(url)).search, + Urls: new Map(), }; var encodingsM3u8Response = await realFetch(url, options); if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) { @@ -465,12 +450,12 @@ twitch-videoad.js text/javascript streamInfo.Encodings = encodingsM3u8; setStreamInfoUrls(streamInfo, encodingsM3u8); serverTime = getServerTimeFromM3u8(encodingsM3u8); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); + var resolutionInfo = streamInfo.Urls.values().next().value; + var streamM3u8Response = await realFetch(resolutionInfo.Url); if (streamM3u8Response.status == 200) { var streamM3u8 = await streamM3u8Response.text(); - if (streamM3u8.includes(AD_SIGNIFIER)) { - await onFoundAd(streamInfo, streamM3u8, false, realFetch, streamM3u8Url); + if (streamM3u8.includes(AD_SIGNIFIER) || SimulatedAdsDepth > 0) { + await onFoundAd(streamInfo, streamM3u8, false, realFetch, resolutionInfo.Url, resolutionInfo); } } else { resolve(streamM3u8Response); @@ -502,23 +487,36 @@ twitch-videoad.js text/javascript function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) { return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8; } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; + function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) { + var encodingsLines = encodingsM3u8.replace('\r', '').split('\n'); + const [targetWidth, targetHeight] = resolutionInfo.Resolution.split('x').map(Number); + var matchedResolutionUrl = null; + var matchedFrameRate = false; + var closestResolutionUrl = null; + var closestResolutionDifference = Infinity; + for (var i = 0; i < encodingsLines.length - 1; i++) { + if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) { + var attributes = parseAttributes(encodingsLines[i]); + var resolution = attributes['RESOLUTION']; + var 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.trimEnd(); + } + } + const [width, height] = resolution.split('x').map(Number); + var difference = Math.abs((width * height) - (targetWidth * targetHeight)); + if (difference < closestResolutionDifference) { + closestResolutionUrl = encodingsLines[i + 1]; + closestResolutionDifference = difference; + } + } + } + } + return closestResolutionUrl.trimEnd(); } function getAccessToken(channelName, playerType, platform) { if (!platform) { @@ -548,9 +546,10 @@ twitch-videoad.js text/javascript } var headers = { 'Client-Id': CLIENT_ID, - 'Client-Integrity': ClientIntegrityHeader, + 'Device-ID': gql_device_id, 'X-Device-Id': gql_device_id, - 'Authorization': AuthorizationHeader + 'Authorization': AuthorizationHeader, + ...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader}) }; return new Promise((resolve, reject) => { const requestId = Math.random().toString(36).substring(2, 15); @@ -590,73 +589,18 @@ twitch-videoad.js text/javascript worker.postMessage({key: key, value: value}); }); } - function makeGmXmlHttpRequest(fetchRequest) { - return new Promise((resolve, reject) => { - GM.xmlHttpRequest({ - method: fetchRequest.options.method, - url: fetchRequest.url, - data: fetchRequest.options.body, - headers: fetchRequest.options.headers, - onload: response => resolve(response), - onerror: error => reject(error) - }); - }); - } - // Taken from https://github.com/dimdenGD/YeahTwitter/blob/9e0520f5abe029f57929795d8de0d2e5d3751cf3/us.js#L48 - function parseHeaders(headersString) { - const headers = new Headers(); - const lines = headersString.trim().split(/[\r\n]+/); - lines.forEach(line => { - const parts = line.split(':'); - const header = parts.shift(); - const value = parts.join(':'); - headers.append(header, value); - }); - return headers; - } - var serverLikesThisBrowser = false; - var serverHatesThisBrowser = false; async function handleWorkerFetchRequest(fetchRequest) { try { - if (serverLikesThisBrowser || !serverHatesThisBrowser) { - const response = await unsafeWindow.realFetch(fetchRequest.url, fetchRequest.options); - const responseBody = await response.text(); - const responseObject = { - id: fetchRequest.id, - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()), - body: responseBody - }; - if (responseObject.status === 200) { - var resp = JSON.parse(responseBody); - if (typeof resp.errors !== 'undefined') { - serverHatesThisBrowser = true; - } else { - serverLikesThisBrowser = true; - } - } - if (serverLikesThisBrowser || !serverHatesThisBrowser) { - return responseObject; - } - } - if (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest !== 'undefined') { - fetchRequest.options.headers['Sec-Ch-Ua'] = '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"'; - fetchRequest.options.headers['Referer'] = 'https://www.twitch.tv/'; - fetchRequest.options.headers['Origin'] = 'https://www.twitch.tv/'; - fetchRequest.options.headers['Host'] = 'gql.twitch.tv'; - const response = await makeGmXmlHttpRequest(fetchRequest); - const responseBody = response.responseText; - const responseObject = { - id: fetchRequest.id, - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(parseHeaders(response.responseHeaders).entries()), - body: responseBody - }; - return responseObject; - } - throw { message: 'Failed to resolve GQL request. Try the userscript version of the ad blocking solution' }; + const response = await window.realFetch(fetchRequest.url, fetchRequest.options); + const responseBody = await response.text(); + const responseObject = { + id: fetchRequest.id, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseBody + }; + return responseObject; } catch (error) { return { id: fetchRequest.id, @@ -665,48 +609,46 @@ twitch-videoad.js text/javascript } } function hookFetch() { - var realFetch = unsafeWindow.fetch; - unsafeWindow.realFetch = realFetch; - unsafeWindow.fetch = function(url, init, ...args) { + var realFetch = window.fetch; + window.realFetch = realFetch; + window.fetch = function(url, init, ...args) { if (typeof url === 'string') { if (url.includes('gql')) { var deviceId = init.headers['X-Device-Id']; if (typeof deviceId !== 'string') { deviceId = init.headers['Device-ID']; } - if (typeof deviceId === 'string') { + if (typeof deviceId === 'string' && gql_device_id != deviceId) { gql_device_id = deviceId; - } - if (gql_device_id) { postTwitchWorkerMessage('UboUpdateDeviceId', gql_device_id); } - if (OPT_ACCESS_TOKEN_PLAYER_TYPE && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture')) { - 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 !== OPT_ACCESS_TOKEN_PLAYER_TYPE) { - replacedPlayerType = newBody[i].variables.playerType; - newBody[i].variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - } - } - } else { - if (newBody?.variables?.playerType && newBody?.variables?.playerType !== OPT_ACCESS_TOKEN_PLAYER_TYPE) { - replacedPlayerType = newBody.variables.playerType; - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - } - } - if (replacedPlayerType) { - console.log(`Replaced '${replacedPlayerType}' player type with '${OPT_ACCESS_TOKEN_PLAYER_TYPE}' player type`); - init.body = JSON.stringify(newBody); - } - } 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 (OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture')) { + 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 !== OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE) { + replacedPlayerType = newBody[i].variables.playerType; + newBody[i].variables.playerType = OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE; + } + } + } else { + if (newBody?.variables?.playerType && newBody?.variables?.playerType !== OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE) { + replacedPlayerType = newBody.variables.playerType; + newBody.variables.playerType = OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE; + } + } + if (replacedPlayerType) { + console.log(`Replaced '${replacedPlayerType}' player type with '${OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE}' player type`); + init.body = JSON.stringify(newBody); + } + } } } return realFetch.apply(this, arguments); @@ -781,20 +723,22 @@ twitch-videoad.js text/javascript var currentQualityLS = localStorage.getItem(lsKeyQuality); var currentMutedLS = localStorage.getItem(lsKeyMuted); var currentVolumeLS = localStorage.getItem(lsKeyVolume); - if (player?.core?.state) { + if (localStorageHookFailed && player?.core?.state) { localStorage.setItem(lsKeyMuted, JSON.stringify({default:player.core.state.muted})); localStorage.setItem(lsKeyVolume, player.core.state.volume); } - if (player?.core?.state?.quality?.group) { + if (localStorageHookFailed && player?.core?.state?.quality?.group) { localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group})); } playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true }); player.play(); - setTimeout(() => { - localStorage.setItem(lsKeyQuality, currentQualityLS); - localStorage.setItem(lsKeyMuted, currentMutedLS); - localStorage.setItem(lsKeyVolume, currentVolumeLS); - }, 3000); + if (localStorageHookFailed) { + setTimeout(() => { + localStorage.setItem(lsKeyQuality, currentQualityLS); + localStorage.setItem(lsKeyMuted, currentMutedLS); + localStorage.setItem(lsKeyVolume, currentVolumeLS); + }, 3000); + } } function onContentLoaded() { // This stops Twitch from pausing the player when in another tab and an ad shows. @@ -827,7 +771,7 @@ twitch-videoad.js text/javascript if (videos.length > 0) { if (hidden.apply(document) === true || (webkitHidden && webkitHidden.apply(document) === true)) { wasVideoPlaying = !videos[0].paused && !videos[0].ended; - } else if (wasVideoPlaying && !videos[0].ended) { + } else if (wasVideoPlaying && !videos[0].ended && videos[0].paused && videos[0].muted) { videos[0].play(); } } @@ -879,19 +823,24 @@ twitch-videoad.js text/javascript } return realGetItem.apply(this, arguments); }; + if (!localStorage.getItem.toString().includes(Object.keys({cachedValues})[0])) { + // These hooks are useful to preserve player state on player reload + // Firefox doesn't allow hooking of localStorage functions but chrome does + localStorageHookFailed = true; + } } - unsafeWindow.reloadTwitchPlayer = reloadTwitchPlayer; - declareOptions(unsafeWindow); + window.reloadTwitchPlayer = reloadTwitchPlayer; + declareOptions(window); hookWindowWorker(); hookFetch(); if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { onContentLoaded(); } else { - unsafeWindow.addEventListener("DOMContentLoaded", function() { + window.addEventListener("DOMContentLoaded", function() { onContentLoaded(); }); } - unsafeWindow.simulateAds = (depth) => { + window.simulateAds = (depth) => { if (depth === undefined || depth < 0) { console.log('Ad depth paramter required (0 = no simulated ad, 1+ = use backup player for given depth)'); return; diff --git a/video-swap-new/video-swap-new.user.js b/video-swap-new/video-swap-new.user.js index 278a825..7c03e1d 100644 --- a/video-swap-new/video-swap-new.user.js +++ b/video-swap-new/video-swap-new.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name TwitchAdSolutions (video-swap-new) // @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.45 +// @version 1.46 // @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap-new/video-swap-new.user.js // @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap-new/video-swap-new.user.js // @description Multiple solutions for blocking Twitch ads (video-swap-new) @@ -9,42 +9,36 @@ // @match *://*.twitch.tv/* // @run-at document-start // @inject-into page -// @grant GM.xmlHttpRequest -// @connect gql.twitch.tv +// @grant none // ==/UserScript== (function() { 'use strict'; - var ourTwitchAdSolutionsVersion = 13;// Used to prevent conflicts with outdated versions of the scripts - if (typeof unsafeWindow === 'undefined') { - unsafeWindow = window; - } - if (typeof unsafeWindow.twitchAdSolutionsVersion !== 'undefined' && unsafeWindow.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { - console.log("skipping video-swap-new as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + unsafeWindow.twitchAdSolutionsVersion); - unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + var ourTwitchAdSolutionsVersion = 14;// Used to prevent conflicts with outdated versions of the scripts + if (typeof window.twitchAdSolutionsVersion !== 'undefined' && window.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { + console.log("skipping video-swap-new as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + window.twitchAdSolutionsVersion); + window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; return; } - unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; function declareOptions(scope) { // Options / globals - scope.OPT_PREROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ]; - scope.OPT_MIDROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ]; + scope.OPT_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ]; scope.OPT_BACKUP_PLATFORM = 'android'; - scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site'; - scope.OPT_STRIP_PARENT_DOMAINS = true; - scope.OPT_SHOW_AD_BANNER = true; + scope.OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE = 'site'; scope.AD_SIGNIFIER = 'stitched-ad'; scope.LIVE_SIGNIFIER = ',live'; scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; // These are only really for Worker scope... scope.StreamInfos = []; scope.StreamInfosByUrl = []; - scope.CurrentChannelNameFromM3U8 = null; // Need this in both scopes. Window scope needs to update this to worker scope. scope.gql_device_id = null; scope.ClientIntegrityHeader = null; - scope.AuthorizationHeader = null; + scope.AuthorizationHeader = undefined; scope.SimulatedAdsDepth = 0; } + var localStorageHookFailed = false; + var adBlockDiv = null; var twitchWorkers = []; var workerStringConflicts = [ 'twitch', @@ -104,8 +98,8 @@ || workerStringReinsert.some((x) => workerString.includes(x)); } function hookWindowWorker() { - var reinsert = getWorkersForReinsert(unsafeWindow.Worker); - var newWorker = class Worker extends getCleanWorker(unsafeWindow.Worker) { + var reinsert = getWorkersForReinsert(window.Worker); + var newWorker = class Worker extends getCleanWorker(window.Worker) { constructor(twitchBlobUrl, options) { var isTwitchWorker = false; try { @@ -122,18 +116,18 @@ ${declareOptions.toString()} ${getAccessToken.toString()} ${gqlRequest.toString()} - ${makeGraphQlPacket.toString()} ${parseAttributes.toString()} ${setStreamInfoUrls.toString()} ${onFoundAd.toString()} ${getWasmWorkerJs.toString()} ${getServerTimeFromM3u8.toString()} ${replaceServerTimeInM3u8.toString()} + ${getStreamUrlForResolution.toString()} var workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}'); declareOptions(self); gql_device_id = ${gql_device_id ? "'" + gql_device_id + "'" : null}; - AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : null}; - ClientIntegrityHeader = ${AuthorizationHeader ? "'" + ClientIntegrityHeader + "'" : null}; + AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined}; + ClientIntegrityHeader = ${ClientIntegrityHeader ? "'" + ClientIntegrityHeader + "'" : null}; self.addEventListener('message', function(e) { if (e.data.key == 'UboUpdateDeviceId') { gql_device_id = e.data.value; @@ -171,25 +165,23 @@ this.addEventListener('message', (e) => { // NOTE: Removed adDiv caching as '.video-player' can change between streams? if (e.data.key == 'UboShowAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads'; - if (OPT_SHOW_AD_BANNER) { - adDiv.style.display = 'block'; - } + if (adBlockDiv == null) { + adBlockDiv = getAdDiv(); + } + if (adBlockDiv != null) { + adBlockDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads'; + adBlockDiv.style.display = 'block'; } } else if (e.data.key == 'UboHideAdBanner') { - var adDiv = getAdDiv(); - if (adDiv != null) { - adDiv.style.display = 'none'; + if (adBlockDiv == null) { + adBlockDiv = getAdDiv(); + } + if (adBlockDiv != null) { + adBlockDiv.style.display = 'none'; } - } else if (e.data.key == 'UboChannelNameM3U8Changed') { - //console.log('M3U8 channel name changed to ' + e.data.value); } else if (e.data.key == 'UboReloadPlayer') { reloadTwitchPlayer(); } else if (e.data.key == 'UboPauseResumePlayer') { - reloadTwitchPlayer(false, true); - } else if (e.data.key == 'UboSeekPlayer') { reloadTwitchPlayer(true); } }); @@ -222,7 +214,7 @@ } } var workerInstance = reinsertWorkers(newWorker, reinsert); - Object.defineProperty(unsafeWindow, 'Worker', { + Object.defineProperty(window, 'Worker', { get: function() { return workerInstance; }, @@ -244,22 +236,33 @@ } function setStreamInfoUrls(streamInfo, encodingsM3u8) { var lines = encodingsM3u8.replace('\r', '').split('\n'); - for (var j = 0; j < lines.length; j++) { - if (!lines[j].startsWith('#') && lines[j].includes('.m3u8')) { - StreamInfosByUrl[lines[j].trimEnd()] = streamInfo; + for (var i = 0; i < lines.length; i++) { + if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) { + StreamInfosByUrl[lines[i].trimEnd()] = streamInfo; + } + if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) { + var attributes = parseAttributes(lines[i]); + var resolution = attributes['RESOLUTION']; + if (resolution) { + var resolutionInfo = { + Resolution: resolution, + FrameRate: attributes['FRAME-RATE'], + Url: lines[i + 1] + }; + streamInfo.Urls.set(lines[i + 1].trimEnd(), resolutionInfo); + } } } } - async function onFoundAd(streamInfo, textStr, reloadPlayer, realFetch, url) { + async function onFoundAd(streamInfo, textStr, reloadPlayer, realFetch, url, resolutionInfo) { var result = textStr; streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); - var playerTypes = streamInfo.IsMidroll ? OPT_MIDROLL_BACKUP_PLAYER_TYPES : OPT_PREROLL_BACKUP_PLAYER_TYPES; + var playerTypes = OPT_BACKUP_PLAYER_TYPES; if (streamInfo.BackupEncodingsStatus.size >= playerTypes.length) { return textStr; } if (streamInfo.BackupEncodings && !streamInfo.BackupEncodings.includes(url)) { - // NOTE: This might cause some freezing issues - var streamM3u8Url = streamInfo.BackupEncodings.match(/^https:.*\.m3u8.*$/m)[0]; + var streamM3u8Url = getStreamUrlForResolution(streamInfo.BackupEncodings, resolutionInfo); var streamM3u8Response = await realFetch(streamM3u8Url); if (streamM3u8Response.status === 200) { return await streamM3u8Response.text(); @@ -279,7 +282,7 @@ var encodingsM3u8Response = await realFetch(urlInfo.href); if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) { var encodingsM3u8 = await encodingsM3u8Response.text(); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8.*$/m)[0]; + var streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, resolutionInfo); var streamM3u8Response = await realFetch(streamM3u8Url); if (streamM3u8Response.status === 200) { var backTextStr = await streamM3u8Response.text(); @@ -288,48 +291,31 @@ backupPlayerTypeInfo = ' (' + playerType + ')'; streamInfo.BackupEncodingsStatus.set(playerType, 1); streamInfo.BackupEncodingsPlayerTypeIndex = i; - if (playerType !== 'embed') { - // Low resolution streams will reduce the number of resolutions in the UI. To fix this we merge the highest low res into the main m3u8 - // TODO: Do a better matching up of the resolutions rather than picking the highest low res for all - var lowResLines = encodingsM3u8.replace('\r', '').split('\n'); - var lowResBestUrl = null; - var lowResSettings = []; - for (var j = 0; j < lowResLines.length; j++) { - if (lowResLines[j].startsWith('#EXT-X-STREAM-INF')) { - var res = parseAttributes(lowResLines[j])['RESOLUTION']; - if (res && lowResLines[j + 1].endsWith('.m3u8')) { - // Assumes resolutions are correctly ordered - lowResBestUrl = lowResLines[j + 1]; - lowResSettings = parseAttributes(lowResLines[j].substring(lowResLines[j].indexOf(':') + 1)); - break; + if (playerType !== 'embed' && streamInfo.Encodings != null) { + // Low resolution streams will reduce the number of resolutions in the UI. To fix this we merge the low res URLs into the main m3u8 + var normalEncodingsM3u8 = streamInfo.Encodings; + var normalLines = normalEncodingsM3u8.replace('\r', '').split('\n'); + for (var j = 0; j < normalLines.length - 1; j++) { + if (normalLines[j].startsWith('#EXT-X-STREAM-INF')) { + var resSettings = parseAttributes(normalLines[j].substring(normalLines[j].indexOf(':') + 1)); + var lowResUrl = getStreamUrlForResolution(encodingsM3u8, streamInfo.Urls.get(normalLines[j + 1].trimEnd())); + var lowResInf = encodingsM3u8.match(new RegExp(`^.*(?=\n.*${lowResUrl})`, 'm'))[0]; + var lowResSettings = parseAttributes(lowResInf.substring(lowResInf.indexOf(':') + 1)); + //console.log('map ' + resSettings['RESOLUTION'] + ' to ' + lowResSettings['RESOLUTION']); + const codecsKey = 'CODECS'; + if (typeof resSettings[codecsKey] === 'string' && typeof lowResSettings[codecsKey] === 'string' && + resSettings[codecsKey].length >= 3 && lowResSettings[codecsKey].length >= 3 && + (resSettings[codecsKey].startsWith('hev') || resSettings[codecsKey].startsWith('hvc')) && + resSettings[codecsKey].substring(0, 3) !== lowResSettings[codecsKey].substring(0, 3) + ) { + console.log('swap ' + resSettings[codecsKey] + ' to ' + lowResSettings[codecsKey]); + normalLines[j] = normalLines[j].replace(/CODECS="[^"]+"/, `CODECS="${lowResSettings[codecsKey]}"`); + console.log(normalLines[j]); } + normalLines[j + 1] = lowResUrl + ' '.repeat(j + 1);// The stream doesn't load unless each url line is unique } } - if (lowResBestUrl != null && streamInfo.Encodings != null) { - var normalEncodingsM3u8 = streamInfo.Encodings; - var normalLines = normalEncodingsM3u8.replace('\r', '').split('\n'); - for (var j = 0; j < normalLines.length - 1; j++) { - if (normalLines[j].startsWith('#EXT-X-STREAM-INF')) { - var resSettings = parseAttributes(normalLines[j].substring(normalLines[j].indexOf(':') + 1)); - const codecsKey = 'CODECS'; - if (typeof resSettings[codecsKey] === 'string' && typeof lowResSettings[codecsKey] === 'string' && - resSettings[codecsKey].length >= 3 && lowResSettings[codecsKey].length >= 3 && - resSettings[codecsKey].substring(0, 3) === 'hev' && - resSettings[codecsKey].substring(0, 3) !== lowResSettings[codecsKey].substring(0, 3) - ) { - console.log('swap ' + resSettings[codecsKey] + ' to ' + lowResSettings[codecsKey]); - normalLines[j] = normalLines[j].replace(/CODECS="[^"]+"/, `CODECS="${lowResSettings[codecsKey]}"`); - console.log(normalLines[j]); - } - var res = parseAttributes(normalLines[j])['RESOLUTION']; - if (res) { - lowResBestUrl += ' ';// The stream doesn't load unless each url line is unique - normalLines[j + 1] = lowResBestUrl; - } - } - } - encodingsM3u8 = normalLines.join('\r\n'); - } + encodingsM3u8 = normalLines.join('\n'); } streamInfo.BackupEncodings = encodingsM3u8; setStreamInfoUrls(streamInfo, encodingsM3u8); @@ -359,6 +345,10 @@ //postMessage({key:'UboHideAdBanner'}); return textStr; } + var currentResolution = streamInfo.Urls.get(url); + if (!currentResolution) { + return textStr; + } var haveAdTags = textStr.includes(AD_SIGNIFIER); if (SimulatedAdsDepth > 0 && (!streamInfo.BackupEncodings || !streamInfo.BackupEncodings.includes(url) || SimulatedAdsDepth - 1 > streamInfo.BackupEncodingsPlayerTypeIndex)) { haveAdTags = true; @@ -395,10 +385,10 @@ } } if (streamInfo.BackupEncodings && haveAdTags) { - textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url); + textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url, currentResolution); } } else if (haveAdTags && !streamInfo.IsMovingOffBackupEncodings) { - textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url); + textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url, currentResolution); } else { postMessage({key:'UboHideAdBanner'}); } @@ -437,14 +427,7 @@ } else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) { var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; - if (CurrentChannelNameFromM3U8 != channelName) { - postMessage({ - key: 'UboChannelNameM3U8Changed', - value: channelName - }); - } - CurrentChannelNameFromM3U8 = channelName; - if (OPT_STRIP_PARENT_DOMAINS) { + if (OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE) { // parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads var tempUrl = new URL(url); tempUrl.searchParams.delete('parent_domains'); @@ -470,7 +453,8 @@ IsMidroll: false, UseFallbackStream: false, ChannelName: channelName, - UsherParams: (new URL(url)).search + UsherParams: (new URL(url)).search, + Urls: new Map(), }; var encodingsM3u8Response = await realFetch(url, options); if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) { @@ -478,12 +462,12 @@ streamInfo.Encodings = encodingsM3u8; setStreamInfoUrls(streamInfo, encodingsM3u8); serverTime = getServerTimeFromM3u8(encodingsM3u8); - var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; - var streamM3u8Response = await realFetch(streamM3u8Url); + var resolutionInfo = streamInfo.Urls.values().next().value; + var streamM3u8Response = await realFetch(resolutionInfo.Url); if (streamM3u8Response.status == 200) { var streamM3u8 = await streamM3u8Response.text(); - if (streamM3u8.includes(AD_SIGNIFIER)) { - await onFoundAd(streamInfo, streamM3u8, false, realFetch, streamM3u8Url); + if (streamM3u8.includes(AD_SIGNIFIER) || SimulatedAdsDepth > 0) { + await onFoundAd(streamInfo, streamM3u8, false, realFetch, resolutionInfo.Url, resolutionInfo); } } else { resolve(streamM3u8Response); @@ -515,23 +499,36 @@ function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) { return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8; } - function makeGraphQlPacket(event, radToken, payload) { - return [{ - operationName: 'ClientSideAdEventHandling_RecordAdEvent', - variables: { - input: { - eventName: event, - eventPayload: JSON.stringify(payload), - radToken, - }, - }, - extensions: { - persistedQuery: { - version: 1, - sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', - }, - }, - }]; + function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) { + var encodingsLines = encodingsM3u8.replace('\r', '').split('\n'); + const [targetWidth, targetHeight] = resolutionInfo.Resolution.split('x').map(Number); + var matchedResolutionUrl = null; + var matchedFrameRate = false; + var closestResolutionUrl = null; + var closestResolutionDifference = Infinity; + for (var i = 0; i < encodingsLines.length - 1; i++) { + if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) { + var attributes = parseAttributes(encodingsLines[i]); + var resolution = attributes['RESOLUTION']; + var 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.trimEnd(); + } + } + const [width, height] = resolution.split('x').map(Number); + var difference = Math.abs((width * height) - (targetWidth * targetHeight)); + if (difference < closestResolutionDifference) { + closestResolutionUrl = encodingsLines[i + 1]; + closestResolutionDifference = difference; + } + } + } + } + return closestResolutionUrl.trimEnd(); } function getAccessToken(channelName, playerType, platform) { if (!platform) { @@ -561,9 +558,10 @@ } var headers = { 'Client-Id': CLIENT_ID, - 'Client-Integrity': ClientIntegrityHeader, + 'Device-ID': gql_device_id, 'X-Device-Id': gql_device_id, - 'Authorization': AuthorizationHeader + 'Authorization': AuthorizationHeader, + ...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader}) }; return new Promise((resolve, reject) => { const requestId = Math.random().toString(36).substring(2, 15); @@ -603,73 +601,18 @@ worker.postMessage({key: key, value: value}); }); } - function makeGmXmlHttpRequest(fetchRequest) { - return new Promise((resolve, reject) => { - GM.xmlHttpRequest({ - method: fetchRequest.options.method, - url: fetchRequest.url, - data: fetchRequest.options.body, - headers: fetchRequest.options.headers, - onload: response => resolve(response), - onerror: error => reject(error) - }); - }); - } - // Taken from https://github.com/dimdenGD/YeahTwitter/blob/9e0520f5abe029f57929795d8de0d2e5d3751cf3/us.js#L48 - function parseHeaders(headersString) { - const headers = new Headers(); - const lines = headersString.trim().split(/[\r\n]+/); - lines.forEach(line => { - const parts = line.split(':'); - const header = parts.shift(); - const value = parts.join(':'); - headers.append(header, value); - }); - return headers; - } - var serverLikesThisBrowser = false; - var serverHatesThisBrowser = false; async function handleWorkerFetchRequest(fetchRequest) { try { - if (serverLikesThisBrowser || !serverHatesThisBrowser) { - const response = await unsafeWindow.realFetch(fetchRequest.url, fetchRequest.options); - const responseBody = await response.text(); - const responseObject = { - id: fetchRequest.id, - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()), - body: responseBody - }; - if (responseObject.status === 200) { - var resp = JSON.parse(responseBody); - if (typeof resp.errors !== 'undefined') { - serverHatesThisBrowser = true; - } else { - serverLikesThisBrowser = true; - } - } - if (serverLikesThisBrowser || !serverHatesThisBrowser) { - return responseObject; - } - } - if (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest !== 'undefined') { - fetchRequest.options.headers['Sec-Ch-Ua'] = '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"'; - fetchRequest.options.headers['Referer'] = 'https://www.twitch.tv/'; - fetchRequest.options.headers['Origin'] = 'https://www.twitch.tv/'; - fetchRequest.options.headers['Host'] = 'gql.twitch.tv'; - const response = await makeGmXmlHttpRequest(fetchRequest); - const responseBody = response.responseText; - const responseObject = { - id: fetchRequest.id, - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(parseHeaders(response.responseHeaders).entries()), - body: responseBody - }; - return responseObject; - } - throw { message: 'Failed to resolve GQL request. Try the userscript version of the ad blocking solution' }; + const response = await window.realFetch(fetchRequest.url, fetchRequest.options); + const responseBody = await response.text(); + const responseObject = { + id: fetchRequest.id, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseBody + }; + return responseObject; } catch (error) { return { id: fetchRequest.id, @@ -678,48 +621,46 @@ } } function hookFetch() { - var realFetch = unsafeWindow.fetch; - unsafeWindow.realFetch = realFetch; - unsafeWindow.fetch = function(url, init, ...args) { + var realFetch = window.fetch; + window.realFetch = realFetch; + window.fetch = function(url, init, ...args) { if (typeof url === 'string') { if (url.includes('gql')) { var deviceId = init.headers['X-Device-Id']; if (typeof deviceId !== 'string') { deviceId = init.headers['Device-ID']; } - if (typeof deviceId === 'string') { + if (typeof deviceId === 'string' && gql_device_id != deviceId) { gql_device_id = deviceId; - } - if (gql_device_id) { postTwitchWorkerMessage('UboUpdateDeviceId', gql_device_id); } - if (OPT_ACCESS_TOKEN_PLAYER_TYPE && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture')) { - 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 !== OPT_ACCESS_TOKEN_PLAYER_TYPE) { - replacedPlayerType = newBody[i].variables.playerType; - newBody[i].variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - } - } - } else { - if (newBody?.variables?.playerType && newBody?.variables?.playerType !== OPT_ACCESS_TOKEN_PLAYER_TYPE) { - replacedPlayerType = newBody.variables.playerType; - newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; - } - } - if (replacedPlayerType) { - console.log(`Replaced '${replacedPlayerType}' player type with '${OPT_ACCESS_TOKEN_PLAYER_TYPE}' player type`); - init.body = JSON.stringify(newBody); - } - } 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 (OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture')) { + 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 !== OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE) { + replacedPlayerType = newBody[i].variables.playerType; + newBody[i].variables.playerType = OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE; + } + } + } else { + if (newBody?.variables?.playerType && newBody?.variables?.playerType !== OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE) { + replacedPlayerType = newBody.variables.playerType; + newBody.variables.playerType = OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE; + } + } + if (replacedPlayerType) { + console.log(`Replaced '${replacedPlayerType}' player type with '${OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE}' player type`); + init.body = JSON.stringify(newBody); + } + } } } return realFetch.apply(this, arguments); @@ -794,20 +735,22 @@ var currentQualityLS = localStorage.getItem(lsKeyQuality); var currentMutedLS = localStorage.getItem(lsKeyMuted); var currentVolumeLS = localStorage.getItem(lsKeyVolume); - if (player?.core?.state) { + if (localStorageHookFailed && player?.core?.state) { localStorage.setItem(lsKeyMuted, JSON.stringify({default:player.core.state.muted})); localStorage.setItem(lsKeyVolume, player.core.state.volume); } - if (player?.core?.state?.quality?.group) { + if (localStorageHookFailed && player?.core?.state?.quality?.group) { localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group})); } playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true }); player.play(); - setTimeout(() => { - localStorage.setItem(lsKeyQuality, currentQualityLS); - localStorage.setItem(lsKeyMuted, currentMutedLS); - localStorage.setItem(lsKeyVolume, currentVolumeLS); - }, 3000); + if (localStorageHookFailed) { + setTimeout(() => { + localStorage.setItem(lsKeyQuality, currentQualityLS); + localStorage.setItem(lsKeyMuted, currentMutedLS); + localStorage.setItem(lsKeyVolume, currentVolumeLS); + }, 3000); + } } function onContentLoaded() { // This stops Twitch from pausing the player when in another tab and an ad shows. @@ -840,7 +783,7 @@ if (videos.length > 0) { if (hidden.apply(document) === true || (webkitHidden && webkitHidden.apply(document) === true)) { wasVideoPlaying = !videos[0].paused && !videos[0].ended; - } else if (wasVideoPlaying && !videos[0].ended) { + } else if (wasVideoPlaying && !videos[0].ended && videos[0].paused && videos[0].muted) { videos[0].play(); } } @@ -892,19 +835,24 @@ } return realGetItem.apply(this, arguments); }; + if (!localStorage.getItem.toString().includes(Object.keys({cachedValues})[0])) { + // These hooks are useful to preserve player state on player reload + // Firefox doesn't allow hooking of localStorage functions but chrome does + localStorageHookFailed = true; + } } - unsafeWindow.reloadTwitchPlayer = reloadTwitchPlayer; - declareOptions(unsafeWindow); + window.reloadTwitchPlayer = reloadTwitchPlayer; + declareOptions(window); hookWindowWorker(); hookFetch(); if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { onContentLoaded(); } else { - unsafeWindow.addEventListener("DOMContentLoaded", function() { + window.addEventListener("DOMContentLoaded", function() { onContentLoaded(); }); } - unsafeWindow.simulateAds = (depth) => { + window.simulateAds = (depth) => { if (depth === undefined || depth < 0) { console.log('Ad depth paramter required (0 = no simulated ad, 1+ = use backup player for given depth)'); return;