Added ad stripping to vaft / video-swap-new

- Improved vaft buffering mitigation
- Changed 2k/4k player reload to only occur when the 2k/4k quality is active
- Added new script "strip" which just strips ad segments
- Removed all usage of "var"
This commit is contained in:
pixeltris
2025-11-20 21:59:48 +00:00
parent 2005773619
commit 9cae451c04
8 changed files with 1658 additions and 767 deletions
+9 -6
View File
@@ -13,9 +13,9 @@ Proxies are the most reliable way of avoiding ads ([buffering / downtime info](f
Alternatively:
- `Alternate Player for Twitch.tv` - [chrome](https://chrome.google.com/webstore/detail/alternate-player-for-twit/bhplkbgoehhhddaoolmakpocnenplmhf) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/twitch_5/)
- `Purple AdBlock` - [chrome](https://chrome.google.com/webstore/detail/purple-adblock/lkgcfobnmghhbhgekffaadadhmeoindg) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/purpleadblock/) / [code](https://github.com/arthurbolsoni/Purple-adblock/)
- `Purple AdBlock` - [chrome](https://chrome.google.com/webstore/detail/purple-adblock/lkgcfobnmghhbhgekffaadadhmeoindg) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/purpleadblock/) / [userscript](https://raw.githubusercontent.com/arthurbolsoni/Purple-adblock/refs/heads/main/platform/tampermonkey/dist/purpleadblocker.user.js) / [code](https://github.com/arthurbolsoni/Purple-adblock/)
- `AdGuard Extra` - [chrome](https://chrome.google.com/webstore/detail/adguard-extra-beta/mglpocjcjbekdckiahfhagndealpkpbj) / [firefox](https://github.com/AdguardTeam/AdGuardExtra/#firefox) / [userscript](https://userscripts.adtidy.org/release/adguard-extra/1.0/adguard-extra.user.js)
- `video-swap-new` - see below
- `vaft` - see below
[Read this for a full list and descriptions.](full-list.md)
@@ -25,16 +25,19 @@ Alternatively:
**There are better / easier to use methods in the above recommendations.**
- video-swap-new - [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap-new/video-swap-new.user.js) / [ublock](https://raw.githubusercontent.com/pixeltris/TwitchAdSolutions/master/video-swap-new/video-swap-new-ublock-origin.js) / [ublock (permalink)](https://raw.githubusercontent.com/pixeltris/TwitchAdSolutions/8d37319d094e3db2923c52f5f43356580505b0c4/video-swap-new/video-swap-new-ublock-origin.js)
- Uses a lower resolution stream during ads.
- vaft - [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js) / [ublock](https://raw.githubusercontent.com/pixeltris/TwitchAdSolutions/master/vaft/vaft-ublock-origin.js) / [ublock (permalink)](https://raw.githubusercontent.com/pixeltris/TwitchAdSolutions/8d37319d094e3db2923c52f5f43356580505b0c4/vaft/vaft-ublock-origin.js)
- The same as `video-swap-new` but attempts to get a clean stream faster (may suffer from more freezing / playback issues).
- Attempts to get a clean stream as fast as it can
- If it fails to get a clean stream it removes ad segements (no playback until ad-free stream is found)
- video-swap-new - [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap-new/video-swap-new.user.js) / [ublock](https://raw.githubusercontent.com/pixeltris/TwitchAdSolutions/master/video-swap-new/video-swap-new-ublock-origin.js) / [ublock (permalink)](https://raw.githubusercontent.com/pixeltris/TwitchAdSolutions/8d37319d094e3db2923c52f5f43356580505b0c4/video-swap-new/video-swap-new-ublock-origin.js)
- Attempts to get a clean stream
- If it fails to get a clean stream it removes ad segments (no playback until ad-free stream is found)
- Not recommended, `vaft` is a better script
## Applying a script (uBlock Origin)
- Navigate to the uBlock Origin Dashboard (the extension options)
- Under the `My filters` tab add `twitch.tv##+js(twitch-videoad)`.
- Under the `Settings` tab, enable `I am an advanced user`, then click the cog that appears. Modify the value of `userResourcesLocation` from `unset` to the full url of the solution you wish to use (if a url is already in use, add a space after the existing url). e.g. `userResourcesLocation https://raw.githubusercontent.com/pixeltris/TwitchAdSolutions/master/video-swap-new/video-swap-new-ublock-origin.js`
- Under the `Settings` tab, enable `I am an advanced user`, then click the cog that appears. Modify the value of `userResourcesLocation` from `unset` to the full url of the solution you wish to use (if a url is already in use, add a space after the existing url). e.g. `userResourcesLocation https://raw.githubusercontent.com/pixeltris/TwitchAdSolutions/master/vaft/vaft-ublock-origin.js`
- To ensure uBlock Origin loads the script I recommend that you disable/enable the uBlock Origin extension (or restart your browser).
To stop using a script remove the filter and make the url `unset`.
+39 -28
View File
@@ -7,11 +7,11 @@
- `TTV LOL PRO (v1)` - [code](https://github.com/younesaassila/ttv-lol-pro/tree/v1)
- The older, deprecated version of `TTV LOL PRO` that still uses TTV LOL-compatible proxies. Only use this if you're having issues with the current version and know what you're doing.
- `Alternate Player for Twitch.tv` - [chrome](https://chrome.google.com/webstore/detail/alternate-player-for-twit/bhplkbgoehhhddaoolmakpocnenplmhf) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/twitch_5/)
- Removes ad segments (no playback until ad-free stream).
- `Purple AdBlock` - [chrome](https://chrome.google.com/webstore/detail/purple-adblock/lkgcfobnmghhbhgekffaadadhmeoindg) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/purpleadblock/) / [code](https://github.com/arthurbolsoni/Purple-adblock/)
- Replaces ad segments with ad-free segments. Proxy fallback which is currently broken. Loading wheel when all methods fail.
- Removes ad segments (no playback until ad-free stream). Can use low resolution stream when available during midroll ads.
- `Purple AdBlock` - [chrome](https://chrome.google.com/webstore/detail/purple-adblock/lkgcfobnmghhbhgekffaadadhmeoindg) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/purpleadblock/) / [userscript](https://raw.githubusercontent.com/arthurbolsoni/Purple-adblock/refs/heads/main/platform/tampermonkey/dist/purpleadblocker.user.js) / [code](https://github.com/arthurbolsoni/Purple-adblock/)
- Replaces ad segments with ad-free segments. Can use a proxy fallback. Removes ad segments if both methods fail (no playback until ad-free stream is found).
- `AdGuard Extra` - [chrome](https://chrome.google.com/webstore/detail/adguard-extra-beta/mglpocjcjbekdckiahfhagndealpkpbj) / [firefox](https://github.com/AdguardTeam/AdGuardExtra/#firefox) / [userscript](https://userscripts.adtidy.org/release/adguard-extra/1.0/adguard-extra.user.js)
- Forked from one of the userscripts from this repo. Should behave similarly to `vaft` / `video-swap-new`.
- Forked from `vaft` (should behave similarly to `vaft`). Attempts to get a high resolution clean stream as fast as it can.
---
@@ -20,6 +20,40 @@
- `luminous-ttv` - [server code](https://github.com/AlyoshaVasilieva/luminous-ttv) / [extension code](https://github.com/AlyoshaVasilieva/luminous-ttv-ext)
- Uses a proxy on the main m3u8 file to get a stream without ads.
## Web browser scripts (userscript)
- https://greasyfork.org/en/scripts/371186-twitch-mute-ads-and-optionally-hide-them/code
- Mute ads and optionally hide them (tweak the settings at the top of the script).
- `vaft` - [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/vaft/vaft.user.js)
- Attempts to get a clean stream as fast as it can.
- If it fails to get a clean stream it removes ad segements (no playback until ad-free stream is found).
- `video-swap-new` - [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap-new/video-swap-new.user.js)
- Attempts to get a clean stream (often low resolution).
- If it fails to get a clean stream it removes ad segments (no playback until ad-free stream is found).
- Not recommended, `vaft` is a better script.
- `strip` - [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/strip/strip.js)
- Strips ad segments (no playback until ad-free stream is found).
- Not recommended, `vaft` is a better script.
## Applications / third party websites
- `streamlink` - [code](https://github.com/streamlink/streamlink) / [website](https://streamlink.github.io/streamlink-twitch-gui/)
- Removes ad segments (no playback until ad-free stream).
- Use [this](https://github.com/2bc4/streamlink-ttvlol) modified file for uninterrupted playback.
- `Xtra for Twitch` (fork) - [apks](https://github.com/crackededed/Xtra/releases) / [code](https://github.com/crackededed/Xtra)
- An alternate Twitch player for Android with extra features, including ad blocking. This currently only uses the TTV LOL API for proxying. However, TTV LOL itself no longer works, so entering a custom proxy URL in settings is required for ad-blocking capabilities. For example: `https://eu.luminous.dev/live/$channel?allow_source=true&allow_audio_only=true&fast_bread=true`
- `ReVanced` - [code](https://github.com/revanced)
- A collection of tools that allows you to patch Twitch and other Android apps such as YouTube to remove ads. ReVanced's Twitch patches use the TTV LOL and PurpleAdBlocker proxies (can be toggled between in settings). The setup is complicated, so anyone who doesn't want the hassle should just use Xtra instead.
- https://github.com/level3tjg/TwitchAdBlock
- Blocks Twitch ads on iOS
- https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/
- Some countries don't get ads. A simple VPN/VPS could be used to block ads by proxying the m3u8 without having to proxy all your traffic (just the initial m3u8).
## Proxy issues
Proxy solutions can have downtime and you'll either see ads or error 2000. This isn't Twitch retaliating.
Buffering may occur at higher resolutions. This happens because traffic comes from a Twitch server closest to the initial m3u8 proxy request. The only solution to this is asking the proxy maintainer to add a proxy in a country closer to you. If they wont then you'll need to use a lower resolution, or use a different ad blocking solution. A VPN might also be better solution for you.
## Web browser extensions (unmaintained)
- `TTV LOL` - [chrome](https://chrome.google.com/webstore/detail/ttv-lol/ofbbahodfeppoklmgjiokgfdgcndngjm) / [code](https://github.com/TTV-LOL/extensions)
@@ -31,30 +65,7 @@
- `ttv-tools` - [firefox (manual install)](https://github.com/Nerixyz/ttv-tools/releases) / [code](https://github.com/Nerixyz/ttv-tools)
- Removes ad segments (no playback until ad-free stream).
## Web browser scripts (userscript)
## Web browser scripts (userscript) (unmaintained)
- https://greasyfork.org/en/scripts/371186-twitch-mute-ads-and-optionally-hide-them/code
- Mute ads and optionally hide them (tweak the settings at the top of the script).
- https://greasyfork.org/en/scripts/415412-twitch-refresh-on-advert/code
- Reloads the player (or page) when it detects the ad banner in DOM.
## Applications / third party websites
- `streamlink` - [code](https://github.com/streamlink/streamlink) / [website](https://streamlink.github.io/streamlink-twitch-gui/)
- Removes ad segments (no playback until ad-free stream).
- Use [this](https://github.com/2bc4/streamlink-ttvlol) modified file for uninterrupted playback.
- `Xtra for Twitch` (fork) - [apks](https://github.com/crackededed/Xtra/releases) / [code](https://github.com/crackededed/Xtra)
- An alternate Twitch player for Android with extra features, including ad blocking. This currently only uses the TTV LOL API for proxying. However, TTV LOL itself no longer works, so entering a custom proxy URL in settings is required for ad-blocking capabilities. For example: `https://eu.luminous.dev/live/$channel?allow_source=true&allow_audio_only=true&fast_bread=true`
- `ReVanced` - [code](https://github.com/revanced)
- A collection of tools that allows you to patch Twitch and other Android apps such as YouTube to remove ads. ReVanced's Twitch patches use the TTV LOL and PurpleAdBlocker proxies (can be toggled between in settings). The setup is complicated, so anyone who doesn't want the hassle should just use Xtra instead.
- https://github.com/level3tjg/TwitchAdBlock
- Blocks Twitch ads on iOS
- https://twitchls.com/
- Uses the `embed` player. Purple screen may display every 10-15 mins.
- https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/
- Some countries don't get ads. A simple VPN/VPS could be used to block ads by proxying the m3u8 without having to proxy all your traffic (just the initial m3u8).
## Proxy issues
Proxy solutions can have downtime and you'll either see ads or error 2000. This isn't Twitch retaliating.
Buffering may occur at higher resolutions. This happens because traffic comes from a Twitch server closest to the initial m3u8 proxy request. The only solution to this is asking the proxy maintainer to add a proxy in a country closer to you. If they wont then you'll need to use a lower resolution, or use a different ad blocking solution. A VPN might also be better solution for you.
+28 -18
View File
@@ -6,37 +6,47 @@ If you're using the uBlock Origin version of the script you need to make sure th
## Streams sometimes appear offline when ads occur
This needs to be fixed but currently the exact cause is unknown.
This needs to be fixed but currently the exact cause is unknown. https://github.com/pixeltris/TwitchAdSolutions/issues/477
## The scripts don't work on mobile (m.twitch.tv)
## The player shows a loading wheel for a long time
If it says `Blocking ads (stripping)` in the top left of the stream then it's actively removing the ad segments but doesn't have a backup stream to show you. If it doesn't say `Blocking ads (stripping)` provide additional information in https://github.com/pixeltris/TwitchAdSolutions/issues/474
## The script don't work on mobile (m.twitch.tv)
There are no plans of implementing the scripts on m.twitch.tv but there are other solutions which blocking ads on Twitch for mobile. See https://github.com/pixeltris/TwitchAdSolutions/blob/master/full-list.md
## Long black screen during ads
The scripts can reload the player when entering/leaving ads. 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.
## `vaft`
### Freezing / buffering
### Freezing / buffering / repeating segments / audio desyncs
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:
`vaft` has a long standing problem of playback problems (freezing / buffering / repeating segments / audio desyncs).
- https://github.com/pixeltris/TwitchAdSolutions/issues/299#issuecomment-2509500697
- https://github.com/pixeltris/TwitchAdSolutions/issues/313
This happens because the script forces the player to consume multiple different m3u8 files during ads and it screws with the player. Simply pressing pause/play often fixes this.
If you're still having problems it's recommended to switch to using `video-swap-new` (which is the recommended script in the README).
The script is configured to do this automatically for you:
### Audio desyncs
```js
PlayerBufferingFix = true;// If true this will pause/play the player when it gets stuck buffering
PlayerBufferingDelay = 500;// How often should we check the player state (in milliseconds)
PlayerBufferingSameStateCount = 3;// How many times of seeing the same player state until we trigger pause/play (it will only trigger it one time until the player state changes again)
PlayerBufferingDangerZone = 1;// The buffering time left (in seconds) when we should ignore the players playback position in the player state check
PlayerBufferingDoPlayerReload = false;// If true this will do a player reload instead of pause/play (player reloading is better at fixing the playback issues but it takes slightly longer)
PlayerBufferingMinRepeatDelay = 5000;// Minimum delay (in milliseconds) between each pause/play (this is to avoid over pressing pause/play when there are genuine buffering problems)
```
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.
- If it triggers but it still freezes try setting `PlayerBufferingDoPlayerReload` to `true`. Player reloads generally have less problems.
- If you're having issues with it triggering when the player is genuinely buffering then adjust `PlayerBufferingDelay` / `PlayerBufferingSameStateCount`.
- If it triggers too frequently then increase the value of `PlayerBufferingMinRepeatDelay`. Decrease this if it triggers, freezes, then has to wait a long time to re-trigger.
- If you don't want to use this and would like to fix the buffering manually yourself you can set `PlayerBufferingFix` to `false`.
- Setting `AlwaysReloadPlayerOnAd` to `true` may reduce freezing issues when entering into ads.
## `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.
There currently isn't a fix for freezing / buffering issues for `video-swap-new`. Try a different script / solution.
+537
View File
@@ -0,0 +1,537 @@
// ==UserScript==
// @name TwitchAdSolutions (strip) - BAD, DON'T USE THIS
// @namespace https://github.com/pixeltris/TwitchAdSolutions
// @version 1.0
// @description Multiple solutions for blocking Twitch ads (strip)
// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/strip/strip.user.js
// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/strip/strip.user.js
// @author pixeltris
// @match *://*.twitch.tv/*
// @run-at document-start
// @inject-into page
// @grant none
// ==/UserScript==
(function() {
'use strict';
const ourTwitchAdSolutionsVersion = 17;// Used to prevent conflicts with outdated versions of the scripts
if (typeof window.twitchAdSolutionsVersion !== 'undefined' && window.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) {
console.log("skipping strip as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + window.twitchAdSolutionsVersion);
window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
return;
}
window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
function declareOptions(scope) {
scope.ReloadPlayerAfterAds = false;// Doesn't seem to help with high latency
scope.ReloadPlayerDelay = 10000;
scope.LastPlayerReload = 0;
scope.ForceAccessTokenPlayerType = 'site';
scope.DisableMatureConentPopup = false;// If true this avoids having to log in to watch age gated content
scope.AdSegmentCache = new Map();
scope.AllSegmentsAreAdSegments = false;
scope.NumStrippedSegments = 0;
scope.M3U8Whitelist = new Set();
scope.M3U8ChannelCache = new Map();
scope.V2API = false;
}
let localStorageHookFailed = false;
let adBlockDiv = null;
const twitchWorkers = [];
const workerStringConflicts = [
'twitch',
'isVariantA'// TwitchNoSub
];
const workerStringAllow = [];
const workerStringReinsert = [
'isVariantA',// TwitchNoSub (prior to (0.9))
'besuper/',// TwitchNoSub (0.9)
'${patch_url}'// TwitchNoSub (0.9.1)
];
function getCleanWorker(worker) {
let root = null;
let parent = null;
let proto = worker;
while (proto) {
const workerString = proto.toString();
if (workerStringConflicts.some((x) => workerString.includes(x)) && !workerStringAllow.some((x) => workerString.includes(x))) {
if (parent !== null) {
Object.setPrototypeOf(parent, Object.getPrototypeOf(proto));
}
} else {
if (root === null) {
root = proto;
}
parent = proto;
}
proto = Object.getPrototypeOf(proto);
}
return root;
}
function getWorkersForReinsert(worker) {
const result = [];
let proto = worker;
while (proto) {
const workerString = proto.toString();
if (workerStringReinsert.some((x) => workerString.includes(x))) {
result.push(proto);
} else {
}
proto = Object.getPrototypeOf(proto);
}
return result;
}
function reinsertWorkers(worker, reinsert) {
let parent = worker;
for (let i = 0; i < reinsert.length; i++) {
Object.setPrototypeOf(reinsert[i], parent);
parent = reinsert[i];
}
return parent;
}
function isValidWorker(worker) {
const workerString = worker.toString();
return !workerStringConflicts.some((x) => workerString.includes(x))
|| workerStringAllow.some((x) => workerString.includes(x))
|| workerStringReinsert.some((x) => workerString.includes(x));
}
function hookWindowWorker() {
const reinsert = getWorkersForReinsert(window.Worker);
const newWorker = class Worker extends getCleanWorker(window.Worker) {
constructor(twitchBlobUrl, options) {
let isTwitchWorker = false;
try {
isTwitchWorker = new URL(twitchBlobUrl).origin.endsWith('.twitch.tv');
} catch {}
if (!isTwitchWorker) {
super(twitchBlobUrl, options);
return;
}
const newBlobStr = `
${stripAdSegments.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getWasmWorkerJs.toString()}
${getServerTimeFromM3u8.toString()}
${replaceServerTimeInM3u8.toString()}
const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}');
declareOptions(self);
self.addEventListener('message', function(e) {
if (e.data.key == 'AllSegmentsAreAdSegments') {
AllSegmentsAreAdSegments = !AllSegmentsAreAdSegments;
console.log('AllSegmentsAreAdSegments: ' + AllSegmentsAreAdSegments);
}
});
hookWorkerFetch();
eval(workerString);
`;
super(URL.createObjectURL(new Blob([newBlobStr])), options);
twitchWorkers.push(this);
this.addEventListener('message', (e) => {
if (e.data.key == 'ReloadPlayer') {
reloadTwitchPlayer();
} else if (e.data.key == 'UpdateAdBlockBannerStripping') {
if (adBlockDiv == null) {
adBlockDiv = getAdBlockDiv();
}
if (adBlockDiv != null) {
if (e.data.isStrippingAdSegments) {
adBlockDiv.P.textContent = 'Stripping' + (e.data.isMidroll ? ' midroll' : '') + ' ads' + (e.data.numStrippedAdSegments > 0 ? ` (${e.data.numStrippedAdSegments})` : '');
adBlockDiv.style.display = 'block';
} else {
adBlockDiv.style.display = 'none';
}
}
}
});
function getAdBlockDiv() {
//To display a notification to the user, that an ad is being blocked.
const playerRootDiv = document.querySelector('.video-player');
let adBlockDiv = null;
if (playerRootDiv != null) {
adBlockDiv = playerRootDiv.querySelector('.adblock-overlay');
if (adBlockDiv == null) {
adBlockDiv = document.createElement('div');
adBlockDiv.className = 'adblock-overlay';
adBlockDiv.innerHTML = '<div class="player-adblock-notice" style="color: white; background-color: rgba(0, 0, 0, 0.8); position: absolute; top: 0px; left: 0px; padding: 5px;"><p></p></div>';
adBlockDiv.style.display = 'none';
adBlockDiv.P = adBlockDiv.querySelector('p');
playerRootDiv.appendChild(adBlockDiv);
}
}
return adBlockDiv;
}
}
};
let workerInstance = reinsertWorkers(newWorker, reinsert);
Object.defineProperty(window, 'Worker', {
get: function() {
return workerInstance;
},
set: function(value) {
if (isValidWorker(value)) {
workerInstance = value;
} else {
console.log('Attempt to set twitch worker denied');
}
}
});
}
function getWasmWorkerJs(twitchBlobUrl) {
const req = new XMLHttpRequest();
req.open('GET', twitchBlobUrl, false);
req.overrideMimeType("text/javascript");
req.send();
return req.responseText;
}
function hookWorkerFetch() {
console.log('hookWorkerFetch (strip)');
const realFetch = fetch;
fetch = async function(url, options) {
if (typeof url === 'string') {
if (AdSegmentCache.has(url)) {
return new Promise(function(resolve, reject) {
const processAfter = async function(response) {
if (response.status === 200) {
resolve(await realFetch('data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA'));
} else {
resolve(response);
}
};
const send = function() {
return realFetch(url, options).then(function(response) {
processAfter(response);
})['catch'](function(err) {
reject(err);
});
};
send();
});
}
if (M3U8Whitelist.has(url)) {
return new Promise(function(resolve, reject) {
const processAfter = async function(response) {
if (response.status === 200) {
resolve(new Response(stripAdSegments(await response.text())));
} else {
resolve(response);
}
};
const send = function() {
return realFetch(url, options).then(function(response) {
processAfter(response);
})['catch'](function(err) {
reject(err);
});
};
send();
});
} else if (url.includes('/channel/hls/') && !url.includes('picture-by-picture')) {
V2API = url.includes('/api/v2/');
return new Promise(function(resolve, reject) {
const processAfter = async function(response) {
if (response.status === 200) {
const channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
let encodingsM3u8 = await response.text();
const cachedM3u8 = M3U8ChannelCache.get(channelName);
if (cachedM3u8 && (await realFetch(cachedM3u8.match(/^https:.*\.m3u8$/m)[0])).status === 200) {
encodingsM3u8 = replaceServerTimeInM3u8(cachedM3u8, getServerTimeFromM3u8(encodingsM3u8));
} else if (encodingsM3u8.includes('.m3u8')) {
M3U8ChannelCache.set(channelName, encodingsM3u8);
}
const lines = encodingsM3u8.replaceAll('\r', '').split('\n');
for (let i = 0; i < lines.length - 1; i++) {
if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) {
M3U8Whitelist.add(lines[i + 1]);
}
}
resolve(new Response(encodingsM3u8));
} else {
resolve(response);
}
};
const send = function() {
return realFetch(url, options).then(function(response) {
processAfter(response);
})['catch'](function(err) {
reject(err);
});
};
send();
});
}
}
return realFetch.apply(this, arguments);
};
}
function getServerTimeFromM3u8(encodingsM3u8) {
if (V2API) {
const matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/);
return matches.length > 1 ? matches[1] : null;
}
const matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"');
return matches.length > 1 ? matches[1] : null;
}
function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) {
if (V2API) {
return newServerTime ? encodingsM3u8.replace(/(#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE=")[^"]+(")/, `$1${newServerTime}$2`) : encodingsM3u8;
}
return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8;
}
function stripAdSegments(textStr, stripAllSegments) {
let hasStrippedAdSegments = false;
let isMidroll = false;
const lines = textStr.replaceAll('\r', '').split('\n');
const newAdUrl = 'https://twitch.tv';
let isLastSegmentLive = false;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Remove tracking urls which appear in the overlay UI
line = line
.replaceAll(/(X-TV-TWITCH-AD-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`)
.replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`);
if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) {
const segmentUrl = lines[i + 1];
if (!AdSegmentCache.has(segmentUrl)) {
NumStrippedSegments++;
}
AdSegmentCache.set(segmentUrl, Date.now());
hasStrippedAdSegments = true;
}
if (line.startsWith('#EXTINF')) {
isLastSegmentLive = line.includes(',live');
}
if (line.includes('stitched-ad')) {
hasStrippedAdSegments = true;
isLastSegmentLive = false;
}
if (line.includes('"MIDROLL"') || line.includes('"midroll"')) {
isMidroll = true;
}
}
if (isLastSegmentLive) {
// NOTE: If the player position is far behind the buffer position it'll hide the banner before playback
hasStrippedAdSegments = false;
}
if (hasStrippedAdSegments) {
// No low latency during ads (otherwise it's possible for the player to prefetch and display ad segments)
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) {
lines[i] = '';
}
}
} else {
if (NumStrippedSegments > 0 && ReloadPlayerAfterAds) {
postMessage({key:'ReloadPlayer'});
}
NumStrippedSegments = 0;
}
AdSegmentCache.forEach((key, value, map) => {
if (value < Date.now() - 120000) {
map.delete(key);
}
});
postMessage({
key: 'UpdateAdBlockBannerStripping',
isStrippingAdSegments: hasStrippedAdSegments,
numStrippedAdSegments: NumStrippedSegments,
isMidroll: isMidroll
});
return lines.join('\n');
}
function hookFetch() {
const realFetch = window.fetch;
window.realFetch = realFetch;
window.fetch = function(url, init, ...args) {
if (typeof url === 'string') {
if (url.includes('gql')) {
if (ForceAccessTokenPlayerType && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture') && !init.body.includes('frontpage')) {
let replacedPlayerType = '';
const newBody = JSON.parse(init.body);
if (Array.isArray(newBody)) {
for (let i = 0; i < newBody.length; i++) {
if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType !== ForceAccessTokenPlayerType) {
replacedPlayerType = newBody[i].variables.playerType;
newBody[i].variables.playerType = ForceAccessTokenPlayerType;
}
}
} else {
if (newBody?.variables?.playerType && newBody?.variables?.playerType !== ForceAccessTokenPlayerType) {
replacedPlayerType = newBody.variables.playerType;
newBody.variables.playerType = ForceAccessTokenPlayerType;
}
}
if (replacedPlayerType) {
console.log(`Replaced '${replacedPlayerType}' player type with '${ForceAccessTokenPlayerType}' player type`);
init.body = JSON.stringify(newBody);
}
}
if (DisableMatureConentPopup) {
const newBody2 = JSON.parse(init.body);
if (Array.isArray(newBody2)) {
let hasRemovedClassification = false;
for (let i = 0; i < newBody2.length; i++) {
if (newBody2[i]?.operationName == 'ContentClassificationContext') {
hasRemovedClassification = true;
// Doesn't seem like it if we remove this element from the array so instead we duplicate another entry into this index. TODO: Find out why
newBody2[i] = newBody2[i == 0 && newBody2.length > 1 ? 1 : 0];
}
}
if (hasRemovedClassification) {
init.body = JSON.stringify(newBody2);
}
}
}
}
}
return realFetch.apply(this, arguments);
};
}
function reloadTwitchPlayer(isPausePlay) {
// Taken from ttv-tools / ffz
// https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts
// https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx
function findReactNode(root, constraint) {
if (root.stateNode && constraint(root.stateNode)) {
return root.stateNode;
}
let node = root.child;
while (node) {
const result = findReactNode(node, constraint);
if (result) {
return result;
}
node = node.sibling;
}
return null;
}
function findReactRootNode() {
let reactRootNode = null;
const rootNode = document.querySelector('#root');
if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) {
reactRootNode = rootNode._reactRootContainer._internalRoot.current;
}
if (reactRootNode == null) {
const containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer'));
if (containerName != null) {
reactRootNode = rootNode[containerName];
}
}
return reactRootNode;
}
const reactRootNode = findReactRootNode();
if (!reactRootNode) {
console.log('Could not find react root');
return;
}
let player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance);
player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null;
const 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 (LastPlayerReload > Date.now() - ReloadPlayerDelay) {
return;
}
LastPlayerReload = Date.now();
const lsKeyQuality = 'video-quality';
const lsKeyMuted = 'video-muted';
const lsKeyVolume = 'volume';
let currentQualityLS = null;
let currentMutedLS = null;
let currentVolumeLS = null;
try {
currentQualityLS = localStorage.getItem(lsKeyQuality);
currentMutedLS = localStorage.getItem(lsKeyMuted);
currentVolumeLS = localStorage.getItem(lsKeyVolume);
if (localStorageHookFailed && player?.core?.state) {
localStorage.setItem(lsKeyMuted, JSON.stringify({default:player.core.state.muted}));
localStorage.setItem(lsKeyVolume, player.core.state.volume);
}
if (localStorageHookFailed && player?.core?.state?.quality?.group) {
localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group}));
}
} catch {}
console.log('Reloading Twitch player');
playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });
player.play();
if (localStorageHookFailed && (currentQualityLS || currentMutedLS || currentVolumeLS)) {
setTimeout(() => {
try {
if (currentQualityLS) {
localStorage.setItem(lsKeyQuality, currentQualityLS);
}
if (currentMutedLS) {
localStorage.setItem(lsKeyMuted, currentMutedLS);
}
if (currentVolumeLS) {
localStorage.setItem(lsKeyVolume, currentVolumeLS);
}
} catch {}
}, 3000);
}
}
function onContentLoaded() {
// Hooks for preserving volume / resolution
try {
const keysToCache = [
'video-quality',
'video-muted',
'volume',
'lowLatencyModeEnabled',// Low Latency
'persistenceEnabled',// Mini Player
];
const cachedValues = new Map();
for (let i = 0; i < keysToCache.length; i++) {
cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i]));
}
const realSetItem = localStorage.setItem;
localStorage.setItem = function(key, value) {
if (cachedValues.has(key)) {
cachedValues.set(key, value);
}
realSetItem.apply(this, arguments);
};
const realGetItem = localStorage.getItem;
localStorage.getItem = function(key) {
if (cachedValues.has(key)) {
return cachedValues.get(key);
}
return realGetItem.apply(this, arguments);
};
if (!localStorage.getItem.toString().includes(Object.keys({cachedValues})[0])) {
// These hooks are useful to preserve player state on player reload
// Firefox doesn't allow hooking of localStorage functions but chrome does
localStorageHookFailed = true;
}
} catch (err) {
console.log('localStorageHooks failed ' + err)
localStorageHookFailed = true;
}
}
window.reloadTwitchPlayer = reloadTwitchPlayer;
window.allSegmentsAreAdSegments = () => {
postTwitchWorkerMessage('AllSegmentsAreAdSegments');
};
declareOptions(window);
hookWindowWorker();
hookFetch();
if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") {
onContentLoaded();
} else {
window.addEventListener("DOMContentLoaded", function() {
onContentLoaded();
});
}
})();
+313 -210
View File
@@ -2,7 +2,7 @@ twitch-videoad.js text/javascript
(function() {
if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; }
'use strict';
var ourTwitchAdSolutionsVersion = 16;// Used to prevent conflicts with outdated versions of the scripts
const ourTwitchAdSolutionsVersion = 17;// 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;
@@ -15,13 +15,17 @@ twitch-videoad.js text/javascript
scope.BackupPlayerTypes = [
'embed',//Source
'site',//Source
'autoplay'//360p
'autoplay',//360p
'picture-by-picture-CACHED'//360p (-CACHED is an internal suffix and is removed)
];
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.AlwaysReloadPlayerOnAd = false;// Always pause/play when entering/leaving ads
scope.ReloadPlayerAfterAd = true;// After the ad finishes do a player reload instead of pause/play
scope.PlayerReloadMinimalRequestsTime = 1500;
scope.PlayerReloadMinimalRequestsPlayerIndex = 0;//embed
scope.HasTriggeredPlayerReload = false;
scope.DisableMatureConentPopup = false;// If true this avoids having to log in to watch age gated content
scope.StreamInfos = [];
scope.StreamInfosByUrl = [];
@@ -31,33 +35,37 @@ twitch-videoad.js text/javascript
scope.ClientIntegrityHeader = null;
scope.AuthorizationHeader = undefined;
scope.SimulatedAdsDepth = 0;
scope.IsPlayerBuffering = false;
scope.LastPausePlay = 0;
scope.FixPlayerBufferingInsideAds = true;
scope.FixPlayerBufferingOutsideAds = false;
scope.DelayBetweenEachPlayerFixBufferAttempt = 3000;
scope.ActiveStreamInfo = null;
scope.PlayerBufferingFix = true;// If true this will pause/play the player when it gets stuck buffering
scope.PlayerBufferingDelay = 500;// How often should we check the player state (in milliseconds)
scope.PlayerBufferingSameStateCount = 3;// How many times of seeing the same player state until we trigger pause/play (it will only trigger it one time until the player state changes again)
scope.PlayerBufferingDangerZone = 1;// The buffering time left (in seconds) when we should ignore the players playback position in the player state check
scope.PlayerBufferingDoPlayerReload = false;// If true this will do a player reload instead of pause/play (player reloading is better at fixing the playback issues but it takes slightly longer)
scope.PlayerBufferingMinRepeatDelay = 5000;// Minimum delay (in milliseconds) between each pause/play (this is to avoid over pressing pause/play when there are genuine buffering problems)
scope.V2API = false;
scope.IsAdStrippingEnabled = true;
scope.AdSegmentCache = new Map();
scope.AllSegmentsAreAdSegments = false;
}
var localStorageHookFailed = false;
var twitchWorkers = [];
var adBlockDiv = null;
var workerStringConflicts = [
let isActivelyStrippingAds = false;
let localStorageHookFailed = false;
const twitchWorkers = [];
let adBlockDiv = null;
const workerStringConflicts = [
'twitch',
'isVariantA'// TwitchNoSub
];
var workerStringAllow = [];
var workerStringReinsert = [
const workerStringAllow = [];
const workerStringReinsert = [
'isVariantA',// TwitchNoSub (prior to (0.9))
'besuper/',// TwitchNoSub (0.9)
'${patch_url}'// TwitchNoSub (0.9.1)
];
function getCleanWorker(worker) {
var root = null;
var parent = null;
var proto = worker;
let root = null;
let parent = null;
let proto = worker;
while (proto) {
var workerString = proto.toString();
const workerString = proto.toString();
if (workerStringConflicts.some((x) => workerString.includes(x)) && !workerStringAllow.some((x) => workerString.includes(x))) {
if (parent !== null) {
Object.setPrototypeOf(parent, Object.getPrototypeOf(proto));
@@ -73,10 +81,10 @@ twitch-videoad.js text/javascript
return root;
}
function getWorkersForReinsert(worker) {
var result = [];
var proto = worker;
const result = [];
let proto = worker;
while (proto) {
var workerString = proto.toString();
const workerString = proto.toString();
if (workerStringReinsert.some((x) => workerString.includes(x))) {
result.push(proto);
} else {
@@ -86,24 +94,24 @@ twitch-videoad.js text/javascript
return result;
}
function reinsertWorkers(worker, reinsert) {
var parent = worker;
for (var i = 0; i < reinsert.length; i++) {
let parent = worker;
for (let i = 0; i < reinsert.length; i++) {
Object.setPrototypeOf(reinsert[i], parent);
parent = reinsert[i];
}
return parent;
}
function isValidWorker(worker) {
var workerString = worker.toString();
const workerString = worker.toString();
return !workerStringConflicts.some((x) => workerString.includes(x))
|| workerStringAllow.some((x) => workerString.includes(x))
|| workerStringReinsert.some((x) => workerString.includes(x));
}
function hookWindowWorker() {
var reinsert = getWorkersForReinsert(window.Worker);
var newWorker = class Worker extends getCleanWorker(window.Worker) {
const reinsert = getWorkersForReinsert(window.Worker);
const newWorker = class Worker extends getCleanWorker(window.Worker) {
constructor(twitchBlobUrl, options) {
var isTwitchWorker = false;
let isTwitchWorker = false;
try {
isTwitchWorker = new URL(twitchBlobUrl).origin.endsWith('.twitch.tv');
} catch {}
@@ -111,8 +119,9 @@ twitch-videoad.js text/javascript
super(twitchBlobUrl, options);
return;
}
var newBlobStr = `
const newBlobStr = `
const pendingFetchRequests = new Map();
${stripAdSegments.toString()}
${getStreamUrlForResolution.toString()}
${processM3U8.toString()}
${hookWorkerFetch.toString()}
@@ -123,8 +132,7 @@ twitch-videoad.js text/javascript
${getWasmWorkerJs.toString()}
${getServerTimeFromM3u8.toString()}
${replaceServerTimeInM3u8.toString()}
${tryFixPlayerBuffering.toString()}
var workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}');
const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}');
declareOptions(self);
GQLDeviceID = ${GQLDeviceID ? "'" + GQLDeviceID + "'" : null};
AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined};
@@ -161,20 +169,14 @@ twitch-videoad.js text/javascript
resolve(response);
}
}
} else if (e.data.key == 'TriggeredPlayerReload') {
HasTriggeredPlayerReload = true;
} 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();
console.log('SimulatedAdsDepth: ' + SimulatedAdsDepth);
} else if (e.data.key == 'AllSegmentsAreAdSegments') {
AllSegmentsAreAdSegments = !AllSegmentsAreAdSegments;
console.log('AllSegmentsAreAdSegments: ' + AllSegmentsAreAdSegments);
}
});
hookWorkerFetch();
@@ -183,25 +185,21 @@ twitch-videoad.js text/javascript
super(URL.createObjectURL(new Blob([newBlobStr])), options);
twitchWorkers.push(this);
this.addEventListener('message', (e) => {
if (e.data.key == 'ShowAdBlockBanner') {
if (e.data.key == 'UpdateAdBlockBanner') {
if (adBlockDiv == null) {
adBlockDiv = getAdBlockDiv();
}
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();
}
if (adBlockDiv != null) {
adBlockDiv.style.display = 'none';
isActivelyStrippingAds = e.data.isStrippingAdSegments;
adBlockDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads' + (e.data.isStrippingAdSegments ? ' (stripping)' : '');
adBlockDiv.style.display = e.data.hasAds ? 'block' : 'none';
}
} else if (e.data.key == 'PauseResumePlayer') {
doTwitchPlayerTask(true, false);
doTwitchPlayerTask(true, false, false);
} else if (e.data.key == 'ReloadPlayer') {
doTwitchPlayerTask(false, true);
doTwitchPlayerTask(false, true, false);
} else if (e.data.key == 'MonitorPlayerForBuffering') {
doTwitchPlayerTask(false, false, true);
}
});
this.addEventListener('message', async event => {
@@ -216,8 +214,8 @@ twitch-videoad.js text/javascript
});
function getAdBlockDiv() {
//To display a notification to the user, that an ad is being blocked.
var playerRootDiv = document.querySelector('.video-player');
var adBlockDiv = null;
const playerRootDiv = document.querySelector('.video-player');
let adBlockDiv = null;
if (playerRootDiv != null) {
adBlockDiv = playerRootDiv.querySelector('.adblock-overlay');
if (adBlockDiv == null) {
@@ -233,7 +231,7 @@ twitch-videoad.js text/javascript
}
}
};
var workerInstance = reinsertWorkers(newWorker, reinsert);
let workerInstance = reinsertWorkers(newWorker, reinsert);
Object.defineProperty(window, 'Worker', {
get: function() {
return workerInstance;
@@ -248,7 +246,7 @@ twitch-videoad.js text/javascript
});
}
function getWasmWorkerJs(twitchBlobUrl) {
var req = new XMLHttpRequest();
const req = new XMLHttpRequest();
req.open('GET', twitchBlobUrl, false);
req.overrideMimeType("text/javascript");
req.send();
@@ -256,20 +254,32 @@ twitch-videoad.js text/javascript
}
function hookWorkerFetch() {
console.log('hookWorkerFetch (vaft)');
var realFetch = fetch;
const realFetch = fetch;
fetch = async function(url, options) {
if (typeof url === 'string') {
if (AdSegmentCache.has(url)) {
return new Promise(function(resolve, reject) {
const send = function() {
return realFetch('data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA', options).then(function(response) {
resolve(response);
})['catch'](function(err) {
reject(err);
});
};
send();
});
}
url = url.trimEnd();
if (url.endsWith('m3u8')) {
return new Promise(function(resolve, reject) {
var processAfter = async function(response) {
const processAfter = async function(response) {
if (response.status === 200) {
resolve(new Response(await processM3U8(url, await response.text(), realFetch)));
} else {
resolve(response);
}
};
var send = function() {
const send = function() {
return realFetch(url, options).then(function(response) {
processAfter(response);
})['catch'](function(err) {
@@ -280,28 +290,30 @@ twitch-videoad.js text/javascript
});
} else if (url.includes('/channel/hls/') && !url.includes('picture-by-picture')) {
V2API = url.includes('/api/v2/');
var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
const channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
if (ForceAccessTokenPlayerType) {
// parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads
var tempUrl = new URL(url);
const tempUrl = new URL(url);
tempUrl.searchParams.delete('parent_domains');
url = tempUrl.toString();
}
return new Promise(function(resolve, reject) {
var processAfter = async function(response) {
const processAfter = async function(response) {
if (response.status == 200) {
var encodingsM3u8 = await response.text();
var serverTime = getServerTimeFromM3u8(encodingsM3u8);
var streamInfo = StreamInfos[channelName];
const encodingsM3u8 = await response.text();
const serverTime = getServerTimeFromM3u8(encodingsM3u8);
let streamInfo = StreamInfos[channelName];
let isNewStream = false;
if (streamInfo != null && streamInfo.EncodingsM3U8 != null && (await realFetch(streamInfo.EncodingsM3U8.match(/^https:.*\.m3u8$/m)[0])).status !== 200) {
// The cached encodings are dead (the stream probably restarted)
streamInfo = null;
}
if (streamInfo == null || streamInfo.EncodingsM3U8 == null) {
isNewStream = true;
StreamInfos[channelName] = streamInfo = {
ChannelName: channelName,
IsShowingAd: false,
AdStartTime: 0,
LastPlayerReload: 0,
EncodingsM3U8: encodingsM3u8,
ModifiedM3U8: null,
IsUsingModifiedM3U8: false,
@@ -311,15 +323,16 @@ twitch-videoad.js text/javascript
ResolutionList: [],
BackupEncodingsM3U8Cache: [],
ActiveBackupPlayerType: null,
IsMidroll: false
IsMidroll: false,
IsStrippingAdSegments: false
};
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length - 1; i++) {
const lines = encodingsM3u8.replaceAll('\r', '').split('\n');
for (let i = 0; i < lines.length - 1; i++) {
if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) {
var attributes = parseAttributes(lines[i]);
var resolution = attributes['RESOLUTION'];
const attributes = parseAttributes(lines[i]);
const resolution = attributes['RESOLUTION'];
if (resolution) {
var resolutionInfo = {
const resolutionInfo = {
Resolution: resolution,
FrameRate: attributes['FRAME-RATE'],
Codecs: attributes['CODECS'],
@@ -331,17 +344,17 @@ twitch-videoad.js text/javascript
StreamInfosByUrl[lines[i + 1]] = streamInfo;
}
}
var nonHevcResolutionList = streamInfo.ResolutionList.filter((element) => element.Codecs.startsWith('avc') || element.Codecs.startsWith('av0'));
const nonHevcResolutionList = streamInfo.ResolutionList.filter((element) => element.Codecs.startsWith('avc') || element.Codecs.startsWith('av0'));
if (AlwaysReloadPlayerOnAd || (nonHevcResolutionList.length > 0 && streamInfo.ResolutionList.some((element) => element.Codecs.startsWith('hev') || element.Codecs.startsWith('hvc')) && !SkipPlayerReloadOnHevc)) {
if (nonHevcResolutionList.length > 0) {
for (var i = 0; i < lines.length - 1; i++) {
for (let 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 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 oldResolution = resSettings['RESOLUTION'];
const [targetWidth, targetHeight] = oldResolution.split('x').map(Number);
var newResolutionInfo = nonHevcResolutionList.sort((a, b) => {
const newResolutionInfo = nonHevcResolutionList.sort((a, b) => {
// TODO: Take into account 'Frame-Rate' when sorting (i.e. 1080p60 vs 1080p30)
const [streamWidthA, streamHeightA] = a.Resolution.split('x').map(Number);
const [streamWidthB, streamHeightB] = b.Resolution.split('x').map(Number);
@@ -356,25 +369,21 @@ twitch-videoad.js text/javascript
}
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;
}
}
}
}
} else {
streamInfo.IsUsingModifiedM3U8 = streamInfo.IsShowingAd && streamInfo.ModifiedM3U8;
}
postMessage({
key: 'MonitorPlayerForBuffering'
});
if (streamInfo.IsUsingModifiedM3U8 || isNewStream) {
streamInfo.LastPlayerReload = Date.now();
}
resolve(new Response(replaceServerTimeInM3u8(streamInfo.IsUsingModifiedM3U8 ? streamInfo.ModifiedM3U8 : streamInfo.EncodingsM3U8, serverTime)));
} else {
resolve(response);
}
};
var send = function() {
const send = function() {
return realFetch(url, options).then(function(response) {
processAfter(response);
})['catch'](function(err) {
@@ -390,10 +399,10 @@ twitch-videoad.js text/javascript
}
function getServerTimeFromM3u8(encodingsM3u8) {
if (V2API) {
var matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/);
const matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/);
return matches.length > 1 ? matches[1] : null;
}
var matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"');
const matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"');
return matches.length > 1 ? matches[1] : null;
}
function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) {
@@ -402,18 +411,53 @@ twitch-videoad.js text/javascript
}
return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8;
}
function stripAdSegments(textStr, stripAllSegments, streamInfo) {
let hasStrippedAdSegments = false;
const lines = textStr.replaceAll('\r', '').split('\n');
const newAdUrl = 'https://twitch.tv';
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Remove tracking urls which appear in the overlay UI
line = line
.replaceAll(/(X-TV-TWITCH-AD-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`)
.replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`);
if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) {
const segmentUrl = lines[i + 1];
AdSegmentCache.set(segmentUrl, Date.now());
hasStrippedAdSegments = true;
}
if (line.includes(AdSignifier)) {
hasStrippedAdSegments = true;
}
}
if (hasStrippedAdSegments) {
for (let i = 0; i < lines.length; i++) {
// No low latency during ads (otherwise it's possible for the player to prefetch and display ad segments)
if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) {
lines[i] = '';
}
}
}
streamInfo.IsStrippingAdSegments = hasStrippedAdSegments;
AdSegmentCache.forEach((key, value, map) => {
if (value < Date.now() - 120000) {
map.delete(key);
}
});
return lines.join('\n');
}
function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) {
var encodingsLines = encodingsM3u8.replace('\r', '').split('\n');
const encodingsLines = encodingsM3u8.replaceAll('\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++) {
let matchedResolutionUrl = null;
let matchedFrameRate = false;
let closestResolutionUrl = null;
let closestResolutionDifference = Infinity;
for (let i = 0; i < encodingsLines.length - 1; i++) {
if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) {
var attributes = parseAttributes(encodingsLines[i]);
var resolution = attributes['RESOLUTION'];
var frameRate = attributes['FRAME-RATE'];
const attributes = parseAttributes(encodingsLines[i]);
const resolution = attributes['RESOLUTION'];
const frameRate = attributes['FRAME-RATE'];
if (resolution) {
if (resolution == resolutionInfo.Resolution && (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) {
matchedResolutionUrl = encodingsLines[i + 1];
@@ -423,7 +467,7 @@ twitch-videoad.js text/javascript
}
}
const [width, height] = resolution.split('x').map(Number);
var difference = Math.abs((width * height) - (targetWidth * targetHeight));
const difference = Math.abs((width * height) - (targetWidth * targetHeight));
if (difference < closestResolutionDifference) {
closestResolutionUrl = encodingsLines[i + 1];
closestResolutionDifference = difference;
@@ -433,31 +477,22 @@ twitch-videoad.js text/javascript
}
return closestResolutionUrl;
}
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: 'PauseResumePlayer'
});
}
}
async function processM3U8(url, textStr, realFetch) {
var streamInfo = StreamInfosByUrl[url];
const streamInfo = StreamInfosByUrl[url];
if (!streamInfo) {
return textStr;
}
ActiveStreamInfo = streamInfo;
var haveAdTags = textStr.includes(AdSignifier) || SimulatedAdsDepth > 0;
if (HasTriggeredPlayerReload) {
HasTriggeredPlayerReload = false;
streamInfo.LastPlayerReload = Date.now();
}
const haveAdTags = textStr.includes(AdSignifier) || SimulatedAdsDepth > 0;
if (haveAdTags) {
streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"');
if (!streamInfo.IsMidroll) {
var lines = textStr.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
const lines = textStr.replaceAll('\r', '').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('#EXTINF') && lines.length > i + 1) {
if (!line.includes(',live') && !streamInfo.RequestedAds.has(lines[i + 1])) {
// Only request one .ts file per .m3u8 request to avoid making too many requests
@@ -469,54 +504,50 @@ twitch-videoad.js text/javascript
}
}
}
streamInfo.Resolution
var currentResolution = streamInfo.Urls[url];
const 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'
});
}
}
const isHevc = currentResolution.Codecs.startsWith('hev') || currentResolution.Codecs.startsWith('hvc');
if (((isHevc && !SkipPlayerReloadOnHevc) || AlwaysReloadPlayerOnAd) && streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) {
streamInfo.IsUsingModifiedM3U8 = true;
streamInfo.LastPlayerReload = Date.now();
postMessage({
key: 'ShowAdBlockBanner',
isMidroll: streamInfo.IsMidroll
key: 'ReloadPlayer'
});
}
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;
let backupPlayerType = null;
let backupM3u8 = null;
let fallbackM3u8 = null;
let startIndex = 0;
let isDoingMinimalRequests = false;
if (streamInfo.LastPlayerReload > Date.now() - PlayerReloadMinimalRequestsTime) {
// When doing player reload there are a lot of requests which causes the backup stream to load in slow. Briefly prefer using a single version to prevent long delays
startIndex = PlayerReloadMinimalRequestsPlayerIndex;
isDoingMinimalRequests = true;
}
for (var playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < BackupPlayerTypes.length; playerTypeIndex++) {
var playerType = BackupPlayerTypes[playerTypeIndex];
for (var i = 0; i < 2; i++) {
for (let playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < BackupPlayerTypes.length; playerTypeIndex++) {
const playerType = BackupPlayerTypes[playerTypeIndex];
const realPlayerType = playerType.replace('-CACHED', '');
const isFullyCachedPlayerType = playerType != realPlayerType;
for (let i = 0; i < 2; i++) {
// This caches the m3u8 if it doesn't have ads. If the already existing cache has ads it fetches a new version (second loop)
var isFreshM3u8 = false;
var encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType];
let isFreshM3u8 = false;
let encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType];
if (!encodingsM3u8) {
isFreshM3u8 = true;
try {
var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, playerType);
const accessTokenResponse = await getAccessToken(streamInfo.ChannelName, realPlayerType);
if (accessTokenResponse.status === 200) {
var accessToken = await accessTokenResponse.json();
var urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams);
const accessToken = await accessTokenResponse.json();
const urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams);
urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
var encodingsM3u8Response = await realFetch(urlInfo.href);
const encodingsM3u8Response = await realFetch(urlInfo.href);
if (encodingsM3u8Response.status === 200) {
encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType] = await encodingsM3u8Response.text();
}
@@ -525,10 +556,10 @@ twitch-videoad.js text/javascript
}
if (encodingsM3u8) {
try {
var streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, currentResolution);
var streamM3u8Response = await realFetch(streamM3u8Url);
const streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, currentResolution);
const streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status == 200) {
var m3u8Text = await streamM3u8Response.text();
const m3u8Text = await streamM3u8Response.text();
if (m3u8Text) {
if (playerType == FallbackPlayerType) {
fallbackM3u8 = m3u8Text;
@@ -538,13 +569,13 @@ twitch-videoad.js text/javascript
backupM3u8 = m3u8Text;
break;
}
if (isDoingMinimalRequests || isFullyCachedPlayerType) {
break;
}
}
}
} catch (err) {}
}
if (startIndex != 0) {
break;
}
streamInfo.BackupEncodingsM3U8Cache[playerType] = null;
if (isFreshM3u8) {
break;
@@ -562,18 +593,34 @@ twitch-videoad.js text/javascript
console.log(`Blocking${(streamInfo.IsMidroll ? ' midroll ' : ' ')}ads (${backupPlayerType})`);
}
}
// TODO: Improve hevc stripping. It should always strip when there is a codec mismatch (both ways)
const stripHevc = isHevc && streamInfo.ModifiedM3U8;
if (IsAdStrippingEnabled || stripHevc) {
textStr = stripAdSegments(textStr, stripHevc, streamInfo);
}
} else if (streamInfo.IsShowingAd) {
console.log('Finished blocking ads');
streamInfo.IsShowingAd = false;
streamInfo.IsStrippingAdSegments = false;
streamInfo.ActiveBackupPlayerType = null;
postMessage({
key: streamInfo.IsUsingModifiedM3U8 ? 'ReloadPlayer' : 'PauseResumePlayer'
});
postMessage({
key: 'HideAdBlockBanner'
});
if (streamInfo.IsUsingModifiedM3U8 || ReloadPlayerAfterAd) {
streamInfo.IsUsingModifiedM3U8 = false;
streamInfo.LastPlayerReload = Date.now();
postMessage({
key: 'ReloadPlayer'
});
} else {
postMessage({
key: 'PauseResumePlayer'
});
}
}
tryFixPlayerBuffering();
postMessage({
key: 'UpdateAdBlockBanner',
isMidroll: streamInfo.IsMidroll,
hasAds: streamInfo.IsShowingAd,
isStrippingAdSegments: streamInfo.IsStrippingAdSegments
});
return textStr;
}
function parseAttributes(str) {
@@ -589,44 +636,41 @@ twitch-videoad.js text/javascript
}));
}
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 }}';
body = {
operationName: 'PlaybackAccessToken_Template',
query: templateQuery,
const body = {
operationName: 'PlaybackAccessToken',
variables: {
'isLive': true,
'login': channelName,
'isVod': false,
'vodID': '',
'playerType': playerType
isLive: true,
login: channelName,
isVod: false,
vodID: "",
playerType: playerType,
platform: playerType == 'autoplay' ? 'android' : 'web'
},
extensions: {
persistedQuery: {
version:1,
sha256Hash:"ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9"
}
}
};
return gqlRequest(body, playerType);
}
function gqlRequest(body, playerType) {
if (!GQLDeviceID) {
var dcharacters = 'abcdefghijklmnopqrstuvwxyz0123456789';
var dcharactersLength = dcharacters.length;
for (var i = 0; i < 32; i++) {
const dcharacters = 'abcdefghijklmnopqrstuvwxyz0123456789';
const dcharactersLength = dcharacters.length;
for (let i = 0; i < 32; i++) {
GQLDeviceID += dcharacters.charAt(Math.floor(Math.random() * dcharactersLength));
}
}
var headers = {
let headers = {
'Client-ID': ClientID,
'Device-ID': GQLDeviceID,
'X-Device-Id': GQLDeviceID,
'Authorization': AuthorizationHeader,
...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader}),
...(ClientVersion && {'Client-Version': ClientVersion}),
...(ClientSession && {'Client-Session-Id': ClientSession})
};
if (playerType != 'site') {
headers = {
'Client-Id': ClientID,
'X-Device-Id': GQLDeviceID
};
}
return new Promise((resolve, reject) => {
const requestId = Math.random().toString(36).substring(2, 15);
const fetchRequest = {
@@ -648,7 +692,50 @@ twitch-videoad.js text/javascript
});
});
}
function doTwitchPlayerTask(isPausePlay, isReload) {
let playerForMonitoringBuffering = null;
const playerBufferState = {
position: 0,
bufferedPosition: 0,
bufferDuration: 0,
numSame: 0,
lastFixTime: 0
};
function monitorPlayerBuffering() {
if (playerForMonitoringBuffering) {
try {
const player = playerForMonitoringBuffering;
if (!player.isPaused() && !player.getHTMLVideoElement()?.ended && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds) {
const position = player.core?.state?.position;
const bufferedPosition = player.core?.state?.bufferedPosition;
const bufferDuration = player.getBufferDuration();
//console.log('position:' + position + ' bufferDuration:' + bufferDuration + ' bufferPosition:' + bufferedPosition);
// NOTE: This could be improved. It currently lets the player fully eat the full buffer before it triggers pause/play
if ((playerBufferState.position == position || bufferDuration < PlayerBufferingDangerZone) &&
playerBufferState.bufferedPosition == bufferedPosition &&
playerBufferState.bufferDuration >= bufferDuration &&
(position != 0 || bufferedPosition != 0 || bufferDuration != 0)
) {
playerBufferState.numSame++;
if (playerBufferState.numSame == PlayerBufferingSameStateCount) {
console.log('Attempt to fix buffering position:' + playerBufferState.position + ' bufferedPosition:' + playerBufferState.bufferedPosition + ' bufferDuration:' + playerBufferState.bufferDuration);
doTwitchPlayerTask(!PlayerBufferingDoPlayerReload, PlayerBufferingDoPlayerReload, false);
playerBufferState.lastFixTime = Date.now();
}
} else {
playerBufferState.numSame = 0;
}
playerBufferState.position = position;
playerBufferState.bufferedPosition = bufferedPosition;
playerBufferState.bufferDuration = bufferDuration;
}
} catch (err) {
console.error('error when monitoring player for buffering: ' + err);
playerForMonitoringBuffering = null;
}
}
setTimeout(monitorPlayerBuffering, PlayerBufferingDelay);
}
function doTwitchPlayerTask(isPausePlay, isReload, monitorPlayerForBuffering) {
function findReactNode(root, constraint) {
if (root.stateNode && constraint(root.stateNode)) {
return root.stateNode;
@@ -664,27 +751,27 @@ twitch-videoad.js text/javascript
return null;
}
function findReactRootNode() {
var reactRootNode = null;
var rootNode = document.querySelector('#root');
let reactRootNode = null;
const rootNode = document.querySelector('#root');
if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) {
reactRootNode = rootNode._reactRootContainer._internalRoot.current;
}
if (reactRootNode == null) {
var containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer'));
const containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer'));
if (containerName != null) {
reactRootNode = rootNode[containerName];
}
}
return reactRootNode;
}
var reactRootNode = findReactRootNode();
const reactRootNode = findReactRootNode();
if (!reactRootNode) {
console.log('Could not find react root');
return;
}
var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance);
let 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);
const playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings);
if (!player) {
console.log('Could not find player');
return;
@@ -693,7 +780,14 @@ twitch-videoad.js text/javascript
console.log('Could not find player state');
return;
}
if (player.paused || player.core?.paused) {
if (!playerForMonitoringBuffering) {
playerForMonitoringBuffering = player;
}
if (player.isPaused() || player.core?.paused) {
return;
}
if (monitorPlayerForBuffering) {
playerBufferState.lastFixTime = 0;
return;
}
if (isPausePlay) {
@@ -705,9 +799,9 @@ twitch-videoad.js text/javascript
const lsKeyQuality = 'video-quality';
const lsKeyMuted = 'video-muted';
const lsKeyVolume = 'volume';
var currentQualityLS = null;
var currentMutedLS = null;
var currentVolumeLS = null;
let currentQualityLS = null;
let currentMutedLS = null;
let currentVolumeLS = null;
try {
currentQualityLS = localStorage.getItem(lsKeyQuality);
currentMutedLS = localStorage.getItem(lsKeyMuted);
@@ -720,7 +814,9 @@ twitch-videoad.js text/javascript
localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group}));
}
} catch {}
console.log('Reloading Twitch player');
playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });
postTwitchWorkerMessage('TriggeredPlayerReload');
player.play();
if (localStorageHookFailed && (currentQualityLS || currentMutedLS || currentVolumeLS)) {
setTimeout(() => {
@@ -740,7 +836,9 @@ twitch-videoad.js text/javascript
return;
}
}
window.reloadTwitchPlayer = doTwitchPlayerTask;
window.reloadTwitchPlayer = () => {
doTwitchPlayerTask(false, true);
};
function postTwitchWorkerMessage(key, value) {
twitchWorkers.forEach((worker) => {
worker.postMessage({key: key, value: value});
@@ -766,13 +864,12 @@ twitch-videoad.js text/javascript
}
}
function hookFetch() {
var realFetch = window.fetch;
const realFetch = window.fetch;
window.realFetch = realFetch;
window.fetch = function(url, init, ...args) {
if (typeof url === 'string') {
if (url.includes('gql')) {
//Device ID is used when notifying Twitch of ads.
var deviceId = init.headers['X-Device-Id'];
let deviceId = init.headers['X-Device-Id'];
if (typeof deviceId !== 'string') {
deviceId = init.headers['Device-ID'];
}
@@ -816,7 +913,7 @@ twitch-videoad.js text/javascript
if (DisableMatureConentPopup) {
const newBody2 = JSON.parse(init.body);
if (Array.isArray(newBody2)) {
var hasRemovedClassification = false;
let hasRemovedClassification = false;
for (let i = 0; i < newBody2.length; i++) {
if (newBody2[i]?.operationName == 'ContentClassificationContext') {
hasRemovedClassification = true;
@@ -853,13 +950,13 @@ twitch-videoad.js text/javascript
}
});
}catch{}
var block = e => {
const block = e => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
};
let wasVideoPlaying = true;
var visibilityChange = e => {
const visibilityChange = e => {
if (typeof chrome !== 'undefined') {
const videos = document.getElementsByTagName('video');
if (videos.length > 0) {
@@ -893,25 +990,25 @@ twitch-videoad.js text/javascript
}catch{}
// Hooks for preserving volume / resolution
try {
var keysToCache = [
const keysToCache = [
'video-quality',
'video-muted',
'volume',
'lowLatencyModeEnabled',// Low Latency
'persistenceEnabled',// Mini Player
];
var cachedValues = new Map();
for (var i = 0; i < keysToCache.length; i++) {
const cachedValues = new Map();
for (let i = 0; i < keysToCache.length; i++) {
cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i]));
}
var realSetItem = localStorage.setItem;
const realSetItem = localStorage.setItem;
localStorage.setItem = function(key, value) {
if (cachedValues.has(key)) {
cachedValues.set(key, value);
}
realSetItem.apply(this, arguments);
};
var realGetItem = localStorage.getItem;
const realGetItem = localStorage.getItem;
localStorage.getItem = function(key) {
if (cachedValues.has(key)) {
return cachedValues.get(key);
@@ -931,6 +1028,9 @@ twitch-videoad.js text/javascript
declareOptions(window);
hookWindowWorker();
hookFetch();
if (PlayerBufferingFix) {
monitorPlayerBuffering();
}
if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") {
onContentLoaded();
} else {
@@ -945,4 +1045,7 @@ twitch-videoad.js text/javascript
}
postTwitchWorkerMessage('SimulateAds', depth);
};
window.allSegmentsAreAdSegments = () => {
postTwitchWorkerMessage('AllSegmentsAreAdSegments');
};
})();
+314 -211
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name TwitchAdSolutions (vaft)
// @namespace https://github.com/pixeltris/TwitchAdSolutions
// @version 29.0.0
// @version 30.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
@@ -13,7 +13,7 @@
// ==/UserScript==
(function() {
'use strict';
var ourTwitchAdSolutionsVersion = 16;// Used to prevent conflicts with outdated versions of the scripts
const ourTwitchAdSolutionsVersion = 17;// 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;
@@ -26,13 +26,17 @@
scope.BackupPlayerTypes = [
'embed',//Source
'site',//Source
'autoplay'//360p
'autoplay',//360p
'picture-by-picture-CACHED'//360p (-CACHED is an internal suffix and is removed)
];
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.AlwaysReloadPlayerOnAd = false;// Always pause/play when entering/leaving ads
scope.ReloadPlayerAfterAd = true;// After the ad finishes do a player reload instead of pause/play
scope.PlayerReloadMinimalRequestsTime = 1500;
scope.PlayerReloadMinimalRequestsPlayerIndex = 0;//embed
scope.HasTriggeredPlayerReload = false;
scope.DisableMatureConentPopup = false;// If true this avoids having to log in to watch age gated content
scope.StreamInfos = [];
scope.StreamInfosByUrl = [];
@@ -42,33 +46,37 @@
scope.ClientIntegrityHeader = null;
scope.AuthorizationHeader = undefined;
scope.SimulatedAdsDepth = 0;
scope.IsPlayerBuffering = false;
scope.LastPausePlay = 0;
scope.FixPlayerBufferingInsideAds = true;
scope.FixPlayerBufferingOutsideAds = false;
scope.DelayBetweenEachPlayerFixBufferAttempt = 3000;
scope.ActiveStreamInfo = null;
scope.PlayerBufferingFix = true;// If true this will pause/play the player when it gets stuck buffering
scope.PlayerBufferingDelay = 500;// How often should we check the player state (in milliseconds)
scope.PlayerBufferingSameStateCount = 3;// How many times of seeing the same player state until we trigger pause/play (it will only trigger it one time until the player state changes again)
scope.PlayerBufferingDangerZone = 1;// The buffering time left (in seconds) when we should ignore the players playback position in the player state check
scope.PlayerBufferingDoPlayerReload = false;// If true this will do a player reload instead of pause/play (player reloading is better at fixing the playback issues but it takes slightly longer)
scope.PlayerBufferingMinRepeatDelay = 5000;// Minimum delay (in milliseconds) between each pause/play (this is to avoid over pressing pause/play when there are genuine buffering problems)
scope.V2API = false;
scope.IsAdStrippingEnabled = true;
scope.AdSegmentCache = new Map();
scope.AllSegmentsAreAdSegments = false;
}
var localStorageHookFailed = false;
var twitchWorkers = [];
var adBlockDiv = null;
var workerStringConflicts = [
let isActivelyStrippingAds = false;
let localStorageHookFailed = false;
const twitchWorkers = [];
let adBlockDiv = null;
const workerStringConflicts = [
'twitch',
'isVariantA'// TwitchNoSub
];
var workerStringAllow = [];
var workerStringReinsert = [
const workerStringAllow = [];
const workerStringReinsert = [
'isVariantA',// TwitchNoSub (prior to (0.9))
'besuper/',// TwitchNoSub (0.9)
'${patch_url}'// TwitchNoSub (0.9.1)
];
function getCleanWorker(worker) {
var root = null;
var parent = null;
var proto = worker;
let root = null;
let parent = null;
let proto = worker;
while (proto) {
var workerString = proto.toString();
const workerString = proto.toString();
if (workerStringConflicts.some((x) => workerString.includes(x)) && !workerStringAllow.some((x) => workerString.includes(x))) {
if (parent !== null) {
Object.setPrototypeOf(parent, Object.getPrototypeOf(proto));
@@ -84,10 +92,10 @@
return root;
}
function getWorkersForReinsert(worker) {
var result = [];
var proto = worker;
const result = [];
let proto = worker;
while (proto) {
var workerString = proto.toString();
const workerString = proto.toString();
if (workerStringReinsert.some((x) => workerString.includes(x))) {
result.push(proto);
} else {
@@ -97,24 +105,24 @@
return result;
}
function reinsertWorkers(worker, reinsert) {
var parent = worker;
for (var i = 0; i < reinsert.length; i++) {
let parent = worker;
for (let i = 0; i < reinsert.length; i++) {
Object.setPrototypeOf(reinsert[i], parent);
parent = reinsert[i];
}
return parent;
}
function isValidWorker(worker) {
var workerString = worker.toString();
const workerString = worker.toString();
return !workerStringConflicts.some((x) => workerString.includes(x))
|| workerStringAllow.some((x) => workerString.includes(x))
|| workerStringReinsert.some((x) => workerString.includes(x));
}
function hookWindowWorker() {
var reinsert = getWorkersForReinsert(window.Worker);
var newWorker = class Worker extends getCleanWorker(window.Worker) {
const reinsert = getWorkersForReinsert(window.Worker);
const newWorker = class Worker extends getCleanWorker(window.Worker) {
constructor(twitchBlobUrl, options) {
var isTwitchWorker = false;
let isTwitchWorker = false;
try {
isTwitchWorker = new URL(twitchBlobUrl).origin.endsWith('.twitch.tv');
} catch {}
@@ -122,8 +130,9 @@
super(twitchBlobUrl, options);
return;
}
var newBlobStr = `
const newBlobStr = `
const pendingFetchRequests = new Map();
${stripAdSegments.toString()}
${getStreamUrlForResolution.toString()}
${processM3U8.toString()}
${hookWorkerFetch.toString()}
@@ -134,8 +143,7 @@
${getWasmWorkerJs.toString()}
${getServerTimeFromM3u8.toString()}
${replaceServerTimeInM3u8.toString()}
${tryFixPlayerBuffering.toString()}
var workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}');
const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}');
declareOptions(self);
GQLDeviceID = ${GQLDeviceID ? "'" + GQLDeviceID + "'" : null};
AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined};
@@ -172,20 +180,14 @@
resolve(response);
}
}
} else if (e.data.key == 'TriggeredPlayerReload') {
HasTriggeredPlayerReload = true;
} 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();
console.log('SimulatedAdsDepth: ' + SimulatedAdsDepth);
} else if (e.data.key == 'AllSegmentsAreAdSegments') {
AllSegmentsAreAdSegments = !AllSegmentsAreAdSegments;
console.log('AllSegmentsAreAdSegments: ' + AllSegmentsAreAdSegments);
}
});
hookWorkerFetch();
@@ -194,25 +196,21 @@
super(URL.createObjectURL(new Blob([newBlobStr])), options);
twitchWorkers.push(this);
this.addEventListener('message', (e) => {
if (e.data.key == 'ShowAdBlockBanner') {
if (e.data.key == 'UpdateAdBlockBanner') {
if (adBlockDiv == null) {
adBlockDiv = getAdBlockDiv();
}
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();
}
if (adBlockDiv != null) {
adBlockDiv.style.display = 'none';
isActivelyStrippingAds = e.data.isStrippingAdSegments;
adBlockDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads' + (e.data.isStrippingAdSegments ? ' (stripping)' : '');
adBlockDiv.style.display = e.data.hasAds ? 'block' : 'none';
}
} else if (e.data.key == 'PauseResumePlayer') {
doTwitchPlayerTask(true, false);
doTwitchPlayerTask(true, false, false);
} else if (e.data.key == 'ReloadPlayer') {
doTwitchPlayerTask(false, true);
doTwitchPlayerTask(false, true, false);
} else if (e.data.key == 'MonitorPlayerForBuffering') {
doTwitchPlayerTask(false, false, true);
}
});
this.addEventListener('message', async event => {
@@ -227,8 +225,8 @@
});
function getAdBlockDiv() {
//To display a notification to the user, that an ad is being blocked.
var playerRootDiv = document.querySelector('.video-player');
var adBlockDiv = null;
const playerRootDiv = document.querySelector('.video-player');
let adBlockDiv = null;
if (playerRootDiv != null) {
adBlockDiv = playerRootDiv.querySelector('.adblock-overlay');
if (adBlockDiv == null) {
@@ -244,7 +242,7 @@
}
}
};
var workerInstance = reinsertWorkers(newWorker, reinsert);
let workerInstance = reinsertWorkers(newWorker, reinsert);
Object.defineProperty(window, 'Worker', {
get: function() {
return workerInstance;
@@ -259,7 +257,7 @@
});
}
function getWasmWorkerJs(twitchBlobUrl) {
var req = new XMLHttpRequest();
const req = new XMLHttpRequest();
req.open('GET', twitchBlobUrl, false);
req.overrideMimeType("text/javascript");
req.send();
@@ -267,20 +265,32 @@
}
function hookWorkerFetch() {
console.log('hookWorkerFetch (vaft)');
var realFetch = fetch;
const realFetch = fetch;
fetch = async function(url, options) {
if (typeof url === 'string') {
if (AdSegmentCache.has(url)) {
return new Promise(function(resolve, reject) {
const send = function() {
return realFetch('data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA', options).then(function(response) {
resolve(response);
})['catch'](function(err) {
reject(err);
});
};
send();
});
}
url = url.trimEnd();
if (url.endsWith('m3u8')) {
return new Promise(function(resolve, reject) {
var processAfter = async function(response) {
const processAfter = async function(response) {
if (response.status === 200) {
resolve(new Response(await processM3U8(url, await response.text(), realFetch)));
} else {
resolve(response);
}
};
var send = function() {
const send = function() {
return realFetch(url, options).then(function(response) {
processAfter(response);
})['catch'](function(err) {
@@ -291,28 +301,30 @@
});
} else if (url.includes('/channel/hls/') && !url.includes('picture-by-picture')) {
V2API = url.includes('/api/v2/');
var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
const channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
if (ForceAccessTokenPlayerType) {
// parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads
var tempUrl = new URL(url);
const tempUrl = new URL(url);
tempUrl.searchParams.delete('parent_domains');
url = tempUrl.toString();
}
return new Promise(function(resolve, reject) {
var processAfter = async function(response) {
const processAfter = async function(response) {
if (response.status == 200) {
var encodingsM3u8 = await response.text();
var serverTime = getServerTimeFromM3u8(encodingsM3u8);
var streamInfo = StreamInfos[channelName];
const encodingsM3u8 = await response.text();
const serverTime = getServerTimeFromM3u8(encodingsM3u8);
let streamInfo = StreamInfos[channelName];
let isNewStream = false;
if (streamInfo != null && streamInfo.EncodingsM3U8 != null && (await realFetch(streamInfo.EncodingsM3U8.match(/^https:.*\.m3u8$/m)[0])).status !== 200) {
// The cached encodings are dead (the stream probably restarted)
streamInfo = null;
}
if (streamInfo == null || streamInfo.EncodingsM3U8 == null) {
isNewStream = true;
StreamInfos[channelName] = streamInfo = {
ChannelName: channelName,
IsShowingAd: false,
AdStartTime: 0,
LastPlayerReload: 0,
EncodingsM3U8: encodingsM3u8,
ModifiedM3U8: null,
IsUsingModifiedM3U8: false,
@@ -322,15 +334,16 @@
ResolutionList: [],
BackupEncodingsM3U8Cache: [],
ActiveBackupPlayerType: null,
IsMidroll: false
IsMidroll: false,
IsStrippingAdSegments: false
};
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length - 1; i++) {
const lines = encodingsM3u8.replaceAll('\r', '').split('\n');
for (let i = 0; i < lines.length - 1; i++) {
if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) {
var attributes = parseAttributes(lines[i]);
var resolution = attributes['RESOLUTION'];
const attributes = parseAttributes(lines[i]);
const resolution = attributes['RESOLUTION'];
if (resolution) {
var resolutionInfo = {
const resolutionInfo = {
Resolution: resolution,
FrameRate: attributes['FRAME-RATE'],
Codecs: attributes['CODECS'],
@@ -342,17 +355,17 @@
StreamInfosByUrl[lines[i + 1]] = streamInfo;
}
}
var nonHevcResolutionList = streamInfo.ResolutionList.filter((element) => element.Codecs.startsWith('avc') || element.Codecs.startsWith('av0'));
const nonHevcResolutionList = streamInfo.ResolutionList.filter((element) => element.Codecs.startsWith('avc') || element.Codecs.startsWith('av0'));
if (AlwaysReloadPlayerOnAd || (nonHevcResolutionList.length > 0 && streamInfo.ResolutionList.some((element) => element.Codecs.startsWith('hev') || element.Codecs.startsWith('hvc')) && !SkipPlayerReloadOnHevc)) {
if (nonHevcResolutionList.length > 0) {
for (var i = 0; i < lines.length - 1; i++) {
for (let 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 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 oldResolution = resSettings['RESOLUTION'];
const [targetWidth, targetHeight] = oldResolution.split('x').map(Number);
var newResolutionInfo = nonHevcResolutionList.sort((a, b) => {
const newResolutionInfo = nonHevcResolutionList.sort((a, b) => {
// TODO: Take into account 'Frame-Rate' when sorting (i.e. 1080p60 vs 1080p30)
const [streamWidthA, streamHeightA] = a.Resolution.split('x').map(Number);
const [streamWidthB, streamHeightB] = b.Resolution.split('x').map(Number);
@@ -367,25 +380,21 @@
}
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;
}
}
}
}
} else {
streamInfo.IsUsingModifiedM3U8 = streamInfo.IsShowingAd && streamInfo.ModifiedM3U8;
}
postMessage({
key: 'MonitorPlayerForBuffering'
});
if (streamInfo.IsUsingModifiedM3U8 || isNewStream) {
streamInfo.LastPlayerReload = Date.now();
}
resolve(new Response(replaceServerTimeInM3u8(streamInfo.IsUsingModifiedM3U8 ? streamInfo.ModifiedM3U8 : streamInfo.EncodingsM3U8, serverTime)));
} else {
resolve(response);
}
};
var send = function() {
const send = function() {
return realFetch(url, options).then(function(response) {
processAfter(response);
})['catch'](function(err) {
@@ -401,10 +410,10 @@
}
function getServerTimeFromM3u8(encodingsM3u8) {
if (V2API) {
var matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/);
const matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/);
return matches.length > 1 ? matches[1] : null;
}
var matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"');
const matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"');
return matches.length > 1 ? matches[1] : null;
}
function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) {
@@ -413,18 +422,53 @@
}
return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8;
}
function stripAdSegments(textStr, stripAllSegments, streamInfo) {
let hasStrippedAdSegments = false;
const lines = textStr.replaceAll('\r', '').split('\n');
const newAdUrl = 'https://twitch.tv';
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Remove tracking urls which appear in the overlay UI
line = line
.replaceAll(/(X-TV-TWITCH-AD-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`)
.replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`);
if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) {
const segmentUrl = lines[i + 1];
AdSegmentCache.set(segmentUrl, Date.now());
hasStrippedAdSegments = true;
}
if (line.includes(AdSignifier)) {
hasStrippedAdSegments = true;
}
}
if (hasStrippedAdSegments) {
for (let i = 0; i < lines.length; i++) {
// No low latency during ads (otherwise it's possible for the player to prefetch and display ad segments)
if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) {
lines[i] = '';
}
}
}
streamInfo.IsStrippingAdSegments = hasStrippedAdSegments;
AdSegmentCache.forEach((key, value, map) => {
if (value < Date.now() - 120000) {
map.delete(key);
}
});
return lines.join('\n');
}
function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) {
var encodingsLines = encodingsM3u8.replace('\r', '').split('\n');
const encodingsLines = encodingsM3u8.replaceAll('\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++) {
let matchedResolutionUrl = null;
let matchedFrameRate = false;
let closestResolutionUrl = null;
let closestResolutionDifference = Infinity;
for (let i = 0; i < encodingsLines.length - 1; i++) {
if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) {
var attributes = parseAttributes(encodingsLines[i]);
var resolution = attributes['RESOLUTION'];
var frameRate = attributes['FRAME-RATE'];
const attributes = parseAttributes(encodingsLines[i]);
const resolution = attributes['RESOLUTION'];
const frameRate = attributes['FRAME-RATE'];
if (resolution) {
if (resolution == resolutionInfo.Resolution && (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) {
matchedResolutionUrl = encodingsLines[i + 1];
@@ -434,7 +478,7 @@
}
}
const [width, height] = resolution.split('x').map(Number);
var difference = Math.abs((width * height) - (targetWidth * targetHeight));
const difference = Math.abs((width * height) - (targetWidth * targetHeight));
if (difference < closestResolutionDifference) {
closestResolutionUrl = encodingsLines[i + 1];
closestResolutionDifference = difference;
@@ -444,31 +488,22 @@
}
return closestResolutionUrl;
}
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: 'PauseResumePlayer'
});
}
}
async function processM3U8(url, textStr, realFetch) {
var streamInfo = StreamInfosByUrl[url];
const streamInfo = StreamInfosByUrl[url];
if (!streamInfo) {
return textStr;
}
ActiveStreamInfo = streamInfo;
var haveAdTags = textStr.includes(AdSignifier) || SimulatedAdsDepth > 0;
if (HasTriggeredPlayerReload) {
HasTriggeredPlayerReload = false;
streamInfo.LastPlayerReload = Date.now();
}
const haveAdTags = textStr.includes(AdSignifier) || SimulatedAdsDepth > 0;
if (haveAdTags) {
streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"');
if (!streamInfo.IsMidroll) {
var lines = textStr.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
const lines = textStr.replaceAll('\r', '').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('#EXTINF') && lines.length > i + 1) {
if (!line.includes(',live') && !streamInfo.RequestedAds.has(lines[i + 1])) {
// Only request one .ts file per .m3u8 request to avoid making too many requests
@@ -480,54 +515,50 @@
}
}
}
streamInfo.Resolution
var currentResolution = streamInfo.Urls[url];
const 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'
});
}
}
const isHevc = currentResolution.Codecs.startsWith('hev') || currentResolution.Codecs.startsWith('hvc');
if (((isHevc && !SkipPlayerReloadOnHevc) || AlwaysReloadPlayerOnAd) && streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) {
streamInfo.IsUsingModifiedM3U8 = true;
streamInfo.LastPlayerReload = Date.now();
postMessage({
key: 'ShowAdBlockBanner',
isMidroll: streamInfo.IsMidroll
key: 'ReloadPlayer'
});
}
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;
let backupPlayerType = null;
let backupM3u8 = null;
let fallbackM3u8 = null;
let startIndex = 0;
let isDoingMinimalRequests = false;
if (streamInfo.LastPlayerReload > Date.now() - PlayerReloadMinimalRequestsTime) {
// When doing player reload there are a lot of requests which causes the backup stream to load in slow. Briefly prefer using a single version to prevent long delays
startIndex = PlayerReloadMinimalRequestsPlayerIndex;
isDoingMinimalRequests = true;
}
for (var playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < BackupPlayerTypes.length; playerTypeIndex++) {
var playerType = BackupPlayerTypes[playerTypeIndex];
for (var i = 0; i < 2; i++) {
for (let playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < BackupPlayerTypes.length; playerTypeIndex++) {
const playerType = BackupPlayerTypes[playerTypeIndex];
const realPlayerType = playerType.replace('-CACHED', '');
const isFullyCachedPlayerType = playerType != realPlayerType;
for (let i = 0; i < 2; i++) {
// This caches the m3u8 if it doesn't have ads. If the already existing cache has ads it fetches a new version (second loop)
var isFreshM3u8 = false;
var encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType];
let isFreshM3u8 = false;
let encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType];
if (!encodingsM3u8) {
isFreshM3u8 = true;
try {
var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, playerType);
const accessTokenResponse = await getAccessToken(streamInfo.ChannelName, realPlayerType);
if (accessTokenResponse.status === 200) {
var accessToken = await accessTokenResponse.json();
var urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams);
const accessToken = await accessTokenResponse.json();
const urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams);
urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
var encodingsM3u8Response = await realFetch(urlInfo.href);
const encodingsM3u8Response = await realFetch(urlInfo.href);
if (encodingsM3u8Response.status === 200) {
encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType] = await encodingsM3u8Response.text();
}
@@ -536,10 +567,10 @@
}
if (encodingsM3u8) {
try {
var streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, currentResolution);
var streamM3u8Response = await realFetch(streamM3u8Url);
const streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, currentResolution);
const streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status == 200) {
var m3u8Text = await streamM3u8Response.text();
const m3u8Text = await streamM3u8Response.text();
if (m3u8Text) {
if (playerType == FallbackPlayerType) {
fallbackM3u8 = m3u8Text;
@@ -549,13 +580,13 @@
backupM3u8 = m3u8Text;
break;
}
if (isDoingMinimalRequests || isFullyCachedPlayerType) {
break;
}
}
}
} catch (err) {}
}
if (startIndex != 0) {
break;
}
streamInfo.BackupEncodingsM3U8Cache[playerType] = null;
if (isFreshM3u8) {
break;
@@ -573,18 +604,34 @@
console.log(`Blocking${(streamInfo.IsMidroll ? ' midroll ' : ' ')}ads (${backupPlayerType})`);
}
}
// TODO: Improve hevc stripping. It should always strip when there is a codec mismatch (both ways)
const stripHevc = isHevc && streamInfo.ModifiedM3U8;
if (IsAdStrippingEnabled || stripHevc) {
textStr = stripAdSegments(textStr, stripHevc, streamInfo);
}
} else if (streamInfo.IsShowingAd) {
console.log('Finished blocking ads');
streamInfo.IsShowingAd = false;
streamInfo.IsStrippingAdSegments = false;
streamInfo.ActiveBackupPlayerType = null;
postMessage({
key: streamInfo.IsUsingModifiedM3U8 ? 'ReloadPlayer' : 'PauseResumePlayer'
});
postMessage({
key: 'HideAdBlockBanner'
});
if (streamInfo.IsUsingModifiedM3U8 || ReloadPlayerAfterAd) {
streamInfo.IsUsingModifiedM3U8 = false;
streamInfo.LastPlayerReload = Date.now();
postMessage({
key: 'ReloadPlayer'
});
} else {
postMessage({
key: 'PauseResumePlayer'
});
}
}
tryFixPlayerBuffering();
postMessage({
key: 'UpdateAdBlockBanner',
isMidroll: streamInfo.IsMidroll,
hasAds: streamInfo.IsShowingAd,
isStrippingAdSegments: streamInfo.IsStrippingAdSegments
});
return textStr;
}
function parseAttributes(str) {
@@ -600,44 +647,41 @@
}));
}
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 }}';
body = {
operationName: 'PlaybackAccessToken_Template',
query: templateQuery,
const body = {
operationName: 'PlaybackAccessToken',
variables: {
'isLive': true,
'login': channelName,
'isVod': false,
'vodID': '',
'playerType': playerType
isLive: true,
login: channelName,
isVod: false,
vodID: "",
playerType: playerType,
platform: playerType == 'autoplay' ? 'android' : 'web'
},
extensions: {
persistedQuery: {
version:1,
sha256Hash:"ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9"
}
}
};
return gqlRequest(body, playerType);
}
function gqlRequest(body, playerType) {
if (!GQLDeviceID) {
var dcharacters = 'abcdefghijklmnopqrstuvwxyz0123456789';
var dcharactersLength = dcharacters.length;
for (var i = 0; i < 32; i++) {
const dcharacters = 'abcdefghijklmnopqrstuvwxyz0123456789';
const dcharactersLength = dcharacters.length;
for (let i = 0; i < 32; i++) {
GQLDeviceID += dcharacters.charAt(Math.floor(Math.random() * dcharactersLength));
}
}
var headers = {
let headers = {
'Client-ID': ClientID,
'Device-ID': GQLDeviceID,
'X-Device-Id': GQLDeviceID,
'Authorization': AuthorizationHeader,
...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader}),
...(ClientVersion && {'Client-Version': ClientVersion}),
...(ClientSession && {'Client-Session-Id': ClientSession})
};
if (playerType != 'site') {
headers = {
'Client-Id': ClientID,
'X-Device-Id': GQLDeviceID
};
}
return new Promise((resolve, reject) => {
const requestId = Math.random().toString(36).substring(2, 15);
const fetchRequest = {
@@ -659,7 +703,50 @@
});
});
}
function doTwitchPlayerTask(isPausePlay, isReload) {
let playerForMonitoringBuffering = null;
const playerBufferState = {
position: 0,
bufferedPosition: 0,
bufferDuration: 0,
numSame: 0,
lastFixTime: 0
};
function monitorPlayerBuffering() {
if (playerForMonitoringBuffering) {
try {
const player = playerForMonitoringBuffering;
if (!player.isPaused() && !player.getHTMLVideoElement()?.ended && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds) {
const position = player.core?.state?.position;
const bufferedPosition = player.core?.state?.bufferedPosition;
const bufferDuration = player.getBufferDuration();
//console.log('position:' + position + ' bufferDuration:' + bufferDuration + ' bufferPosition:' + bufferedPosition);
// NOTE: This could be improved. It currently lets the player fully eat the full buffer before it triggers pause/play
if ((playerBufferState.position == position || bufferDuration < PlayerBufferingDangerZone) &&
playerBufferState.bufferedPosition == bufferedPosition &&
playerBufferState.bufferDuration >= bufferDuration &&
(position != 0 || bufferedPosition != 0 || bufferDuration != 0)
) {
playerBufferState.numSame++;
if (playerBufferState.numSame == PlayerBufferingSameStateCount) {
console.log('Attempt to fix buffering position:' + playerBufferState.position + ' bufferedPosition:' + playerBufferState.bufferedPosition + ' bufferDuration:' + playerBufferState.bufferDuration);
doTwitchPlayerTask(!PlayerBufferingDoPlayerReload, PlayerBufferingDoPlayerReload, false);
playerBufferState.lastFixTime = Date.now();
}
} else {
playerBufferState.numSame = 0;
}
playerBufferState.position = position;
playerBufferState.bufferedPosition = bufferedPosition;
playerBufferState.bufferDuration = bufferDuration;
}
} catch (err) {
console.error('error when monitoring player for buffering: ' + err);
playerForMonitoringBuffering = null;
}
}
setTimeout(monitorPlayerBuffering, PlayerBufferingDelay);
}
function doTwitchPlayerTask(isPausePlay, isReload, monitorPlayerForBuffering) {
function findReactNode(root, constraint) {
if (root.stateNode && constraint(root.stateNode)) {
return root.stateNode;
@@ -675,27 +762,27 @@
return null;
}
function findReactRootNode() {
var reactRootNode = null;
var rootNode = document.querySelector('#root');
let reactRootNode = null;
const rootNode = document.querySelector('#root');
if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) {
reactRootNode = rootNode._reactRootContainer._internalRoot.current;
}
if (reactRootNode == null) {
var containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer'));
const containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer'));
if (containerName != null) {
reactRootNode = rootNode[containerName];
}
}
return reactRootNode;
}
var reactRootNode = findReactRootNode();
const reactRootNode = findReactRootNode();
if (!reactRootNode) {
console.log('Could not find react root');
return;
}
var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance);
let 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);
const playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings);
if (!player) {
console.log('Could not find player');
return;
@@ -704,7 +791,14 @@
console.log('Could not find player state');
return;
}
if (player.paused || player.core?.paused) {
if (!playerForMonitoringBuffering) {
playerForMonitoringBuffering = player;
}
if (player.isPaused() || player.core?.paused) {
return;
}
if (monitorPlayerForBuffering) {
playerBufferState.lastFixTime = 0;
return;
}
if (isPausePlay) {
@@ -716,9 +810,9 @@
const lsKeyQuality = 'video-quality';
const lsKeyMuted = 'video-muted';
const lsKeyVolume = 'volume';
var currentQualityLS = null;
var currentMutedLS = null;
var currentVolumeLS = null;
let currentQualityLS = null;
let currentMutedLS = null;
let currentVolumeLS = null;
try {
currentQualityLS = localStorage.getItem(lsKeyQuality);
currentMutedLS = localStorage.getItem(lsKeyMuted);
@@ -731,7 +825,9 @@
localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group}));
}
} catch {}
console.log('Reloading Twitch player');
playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });
postTwitchWorkerMessage('TriggeredPlayerReload');
player.play();
if (localStorageHookFailed && (currentQualityLS || currentMutedLS || currentVolumeLS)) {
setTimeout(() => {
@@ -751,7 +847,9 @@
return;
}
}
window.reloadTwitchPlayer = doTwitchPlayerTask;
window.reloadTwitchPlayer = () => {
doTwitchPlayerTask(false, true);
};
function postTwitchWorkerMessage(key, value) {
twitchWorkers.forEach((worker) => {
worker.postMessage({key: key, value: value});
@@ -777,13 +875,12 @@
}
}
function hookFetch() {
var realFetch = window.fetch;
const realFetch = window.fetch;
window.realFetch = realFetch;
window.fetch = function(url, init, ...args) {
if (typeof url === 'string') {
if (url.includes('gql')) {
//Device ID is used when notifying Twitch of ads.
var deviceId = init.headers['X-Device-Id'];
let deviceId = init.headers['X-Device-Id'];
if (typeof deviceId !== 'string') {
deviceId = init.headers['Device-ID'];
}
@@ -827,7 +924,7 @@
if (DisableMatureConentPopup) {
const newBody2 = JSON.parse(init.body);
if (Array.isArray(newBody2)) {
var hasRemovedClassification = false;
let hasRemovedClassification = false;
for (let i = 0; i < newBody2.length; i++) {
if (newBody2[i]?.operationName == 'ContentClassificationContext') {
hasRemovedClassification = true;
@@ -864,13 +961,13 @@
}
});
}catch{}
var block = e => {
const block = e => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
};
let wasVideoPlaying = true;
var visibilityChange = e => {
const visibilityChange = e => {
if (typeof chrome !== 'undefined') {
const videos = document.getElementsByTagName('video');
if (videos.length > 0) {
@@ -904,25 +1001,25 @@
}catch{}
// Hooks for preserving volume / resolution
try {
var keysToCache = [
const keysToCache = [
'video-quality',
'video-muted',
'volume',
'lowLatencyModeEnabled',// Low Latency
'persistenceEnabled',// Mini Player
];
var cachedValues = new Map();
for (var i = 0; i < keysToCache.length; i++) {
const cachedValues = new Map();
for (let i = 0; i < keysToCache.length; i++) {
cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i]));
}
var realSetItem = localStorage.setItem;
const realSetItem = localStorage.setItem;
localStorage.setItem = function(key, value) {
if (cachedValues.has(key)) {
cachedValues.set(key, value);
}
realSetItem.apply(this, arguments);
};
var realGetItem = localStorage.getItem;
const realGetItem = localStorage.getItem;
localStorage.getItem = function(key) {
if (cachedValues.has(key)) {
return cachedValues.get(key);
@@ -942,6 +1039,9 @@
declareOptions(window);
hookWindowWorker();
hookFetch();
if (PlayerBufferingFix) {
monitorPlayerBuffering();
}
if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") {
onContentLoaded();
} else {
@@ -956,4 +1056,7 @@
}
postTwitchWorkerMessage('SimulateAds', depth);
};
window.allSegmentsAreAdSegments = () => {
postTwitchWorkerMessage('AllSegmentsAreAdSegments');
};
})();
+208 -146
View File
@@ -1,7 +1,7 @@
twitch-videoad.js text/javascript
(function() {
if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; }
var ourTwitchAdSolutionsVersion = 16;// Used to prevent conflicts with outdated versions of the scripts
const ourTwitchAdSolutionsVersion = 17;// 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;
@@ -10,8 +10,7 @@ twitch-videoad.js text/javascript
window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
function declareOptions(scope) {
// Options / globals
scope.OPT_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ];
scope.OPT_BACKUP_PLATFORM = 'android';
scope.OPT_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'autoplay-ALT', 'embed' ];
scope.OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE = 'site';
scope.OPT_DISABLE_MATURE_CONTENT_POPUP = false;// If true this avoids having to log in to watch age gated content
scope.AD_SIGNIFIER = 'stitched-ad';
@@ -26,26 +25,29 @@ twitch-videoad.js text/javascript
scope.AuthorizationHeader = undefined;
scope.SimulatedAdsDepth = 0;
scope.V2API = false;
scope.IsAdStrippingEnabled = true;
scope.AdSegmentCache = new Map();
scope.AllSegmentsAreAdSegments = false;
}
var localStorageHookFailed = false;
var adBlockDiv = null;
var twitchWorkers = [];
var workerStringConflicts = [
let localStorageHookFailed = false;
let adBlockDiv = null;
const twitchWorkers = [];
const workerStringConflicts = [
'twitch',
'isVariantA'// TwitchNoSub
];
var workerStringAllow = [];
var workerStringReinsert = [
const workerStringAllow = [];
const workerStringReinsert = [
'isVariantA',// TwitchNoSub (prior to (0.9))
'besuper/',// TwitchNoSub (0.9)
'${patch_url}'// TwitchNoSub (0.9.1)
];
function getCleanWorker(worker) {
var root = null;
var parent = null;
var proto = worker;
let root = null;
let parent = null;
let proto = worker;
while (proto) {
var workerString = proto.toString();
const workerString = proto.toString();
if (workerStringConflicts.some((x) => workerString.includes(x)) && !workerStringAllow.some((x) => workerString.includes(x))) {
if (parent !== null) {
Object.setPrototypeOf(parent, Object.getPrototypeOf(proto));
@@ -61,10 +63,10 @@ twitch-videoad.js text/javascript
return root;
}
function getWorkersForReinsert(worker) {
var result = [];
var proto = worker;
const result = [];
let proto = worker;
while (proto) {
var workerString = proto.toString();
const workerString = proto.toString();
if (workerStringReinsert.some((x) => workerString.includes(x))) {
result.push(proto);
} else {
@@ -74,24 +76,24 @@ twitch-videoad.js text/javascript
return result;
}
function reinsertWorkers(worker, reinsert) {
var parent = worker;
for (var i = 0; i < reinsert.length; i++) {
let parent = worker;
for (let i = 0; i < reinsert.length; i++) {
Object.setPrototypeOf(reinsert[i], parent);
parent = reinsert[i];
}
return parent;
}
function isValidWorker(worker) {
var workerString = worker.toString();
const workerString = worker.toString();
return !workerStringConflicts.some((x) => workerString.includes(x))
|| workerStringAllow.some((x) => workerString.includes(x))
|| workerStringReinsert.some((x) => workerString.includes(x));
}
function hookWindowWorker() {
var reinsert = getWorkersForReinsert(window.Worker);
var newWorker = class Worker extends getCleanWorker(window.Worker) {
const reinsert = getWorkersForReinsert(window.Worker);
const newWorker = class Worker extends getCleanWorker(window.Worker) {
constructor(twitchBlobUrl, options) {
var isTwitchWorker = false;
let isTwitchWorker = false;
try {
isTwitchWorker = new URL(twitchBlobUrl).origin.endsWith('.twitch.tv');
} catch {}
@@ -99,8 +101,9 @@ twitch-videoad.js text/javascript
super(twitchBlobUrl, options);
return;
}
var newBlobStr = `
const newBlobStr = `
const pendingFetchRequests = new Map();
${stripAdSegments.toString()}
${processM3U8.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
@@ -113,7 +116,7 @@ twitch-videoad.js text/javascript
${getServerTimeFromM3u8.toString()}
${replaceServerTimeInM3u8.toString()}
${getStreamUrlForResolution.toString()}
var workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}');
const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}');
declareOptions(self);
gql_device_id = ${gql_device_id ? "'" + gql_device_id + "'" : null};
AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined};
@@ -144,7 +147,10 @@ twitch-videoad.js text/javascript
}
} else if (e.data.key == 'SimulateAds') {
SimulatedAdsDepth = e.data.value;
console.log('SimulatedAdsDepth:' + SimulatedAdsDepth);
console.log('SimulatedAdsDepth: ' + SimulatedAdsDepth);
} else if (e.data.key == 'AllSegmentsAreAdSegments') {
AllSegmentsAreAdSegments = !AllSegmentsAreAdSegments;
console.log('AllSegmentsAreAdSegments: ' + AllSegmentsAreAdSegments);
}
});
hookWorkerFetch();
@@ -154,13 +160,24 @@ twitch-videoad.js text/javascript
twitchWorkers.push(this);
this.addEventListener('message', (e) => {
// NOTE: Removed adDiv caching as '.video-player' can change between streams?
if (e.data.key == 'UboShowAdBanner') {
if (e.data.key == 'UboShowAdBanner' || e.data.key == 'UpdateAdBlockBannerStripping') {
if (adBlockDiv == null) {
adBlockDiv = getAdDiv();
}
if (adBlockDiv != null) {
adBlockDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads';
adBlockDiv.style.display = 'block';
if (e.data.key == 'UpdateAdBlockBannerStripping') {
const strippingAdsSuffix = ' (stripping)';
if (adBlockDiv.P.textContent.endsWith(strippingAdsSuffix)) {
if (!e.data.isStrippingAdSegments) {
adBlockDiv.P.textContent = adBlockDiv.P.textContent.substring(0, adBlockDiv.P.textContent.length - strippingAdsSuffix.length);
}
} else if (e.data.isStrippingAdSegments) {
adBlockDiv.P.textContent += strippingAdsSuffix;
}
} else {
adBlockDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads';
adBlockDiv.style.display = 'block';
}
}
} else if (e.data.key == 'UboHideAdBanner') {
if (adBlockDiv == null) {
@@ -170,7 +187,7 @@ twitch-videoad.js text/javascript
adBlockDiv.style.display = 'none';
}
} else if (e.data.key == 'UboReloadPlayer') {
reloadTwitchPlayer();
reloadTwitchPlayer(false);
} else if (e.data.key == 'UboPauseResumePlayer') {
reloadTwitchPlayer(true);
}
@@ -186,8 +203,8 @@ twitch-videoad.js text/javascript
}
});
function getAdDiv() {
var playerRootDiv = document.querySelector('.video-player');
var adDiv = null;
const playerRootDiv = document.querySelector('.video-player');
let adDiv = null;
if (playerRootDiv != null) {
adDiv = playerRootDiv.querySelector('.ubo-overlay');
if (adDiv == null) {
@@ -203,7 +220,7 @@ twitch-videoad.js text/javascript
}
}
}
var workerInstance = reinsertWorkers(newWorker, reinsert);
let workerInstance = reinsertWorkers(newWorker, reinsert);
Object.defineProperty(window, 'Worker', {
get: function() {
return workerInstance;
@@ -218,23 +235,23 @@ twitch-videoad.js text/javascript
});
}
function getWasmWorkerJs(twitchBlobUrl) {
var req = new XMLHttpRequest();
const req = new XMLHttpRequest();
req.open('GET', twitchBlobUrl, false);
req.overrideMimeType("text/javascript");
req.send();
return req.responseText;
}
function setStreamInfoUrls(streamInfo, encodingsM3u8) {
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
const lines = encodingsM3u8.replaceAll('\r', '').split('\n');
for (let 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'];
const attributes = parseAttributes(lines[i]);
const resolution = attributes['RESOLUTION'];
if (resolution) {
var resolutionInfo = {
const resolutionInfo = {
Resolution: resolution,
FrameRate: attributes['FRAME-RATE'],
Url: lines[i + 1]
@@ -245,37 +262,37 @@ twitch-videoad.js text/javascript
}
}
async function onFoundAd(streamInfo, textStr, reloadPlayer, realFetch, url, resolutionInfo) {
var result = textStr;
let result = textStr;
streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"');
var playerTypes = OPT_BACKUP_PLAYER_TYPES;
const playerTypes = OPT_BACKUP_PLAYER_TYPES;
if (streamInfo.BackupEncodingsStatus.size >= playerTypes.length) {
return textStr;
}
if (streamInfo.BackupEncodings && !streamInfo.BackupEncodings.includes(url)) {
var streamM3u8Url = getStreamUrlForResolution(streamInfo.BackupEncodings, resolutionInfo);
var streamM3u8Response = await realFetch(streamM3u8Url);
const streamM3u8Url = getStreamUrlForResolution(streamInfo.BackupEncodings, resolutionInfo);
const streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status === 200) {
return await streamM3u8Response.text();
}
}
var backupPlayerTypeInfo = '';
for (var i = 0; i < playerTypes.length; i++) {
var playerType = playerTypes[i];
let backupPlayerTypeInfo = '';
for (let i = 0; i < playerTypes.length; i++) {
const playerType = playerTypes[i];
if (!streamInfo.BackupEncodingsStatus.has(playerType)) {
try {
var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, playerType, OPT_BACKUP_PLATFORM);
const accessTokenResponse = await getAccessToken(streamInfo.ChannelName, playerType);
if (accessTokenResponse != null && accessTokenResponse.status === 200) {
var accessToken = await accessTokenResponse.json();
var urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams);
const accessToken = await accessTokenResponse.json();
const urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams);
urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
var encodingsM3u8Response = await realFetch(urlInfo.href);
const encodingsM3u8Response = await realFetch(urlInfo.href);
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
var encodingsM3u8 = await encodingsM3u8Response.text();
var streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, resolutionInfo);
var streamM3u8Response = await realFetch(streamM3u8Url);
let encodingsM3u8 = await encodingsM3u8Response.text();
const streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, resolutionInfo);
const streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status === 200) {
var backTextStr = await streamM3u8Response.text();
const backTextStr = await streamM3u8Response.text();
if ((!backTextStr.includes(AD_SIGNIFIER) && (SimulatedAdsDepth == 0 || i >= SimulatedAdsDepth - 1)) || i >= playerTypes.length - 1) {
result = backTextStr;
backupPlayerTypeInfo = ' (' + playerType + ')';
@@ -283,14 +300,14 @@ twitch-videoad.js text/javascript
streamInfo.BackupEncodingsPlayerTypeIndex = i;
if (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++) {
const normalEncodingsM3u8 = streamInfo.Encodings;
const normalLines = normalEncodingsM3u8.replaceAll('\r', '').split('\n');
for (let 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));
const resSettings = parseAttributes(normalLines[j].substring(normalLines[j].indexOf(':') + 1));
const lowResUrl = getStreamUrlForResolution(encodingsM3u8, streamInfo.Urls.get(normalLines[j + 1].trimEnd()));
const lowResInf = encodingsM3u8.match(new RegExp(`^.*(?=\n.*${lowResUrl})`, 'm'))[0];
const 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' &&
@@ -328,26 +345,59 @@ twitch-videoad.js text/javascript
postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll});
return result;
}
function stripAdSegments(textStr, stripAllSegments) {
let hasStrippedAdSegments = false;
const lines = textStr.replaceAll('\r', '').split('\n');
const newAdUrl = 'https://twitch.tv';
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Remove tracking urls which appear in the overlay UI
line = line
.replaceAll(/(X-TV-TWITCH-AD-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`)
.replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`);
if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) {
const segmentUrl = lines[i + 1];
AdSegmentCache.set(segmentUrl, Date.now());
hasStrippedAdSegments = true;
}
if (line.includes(AD_SIGNIFIER)) {
hasStrippedAdSegments = true;
}
}
if (hasStrippedAdSegments) {
for (let i = 0; i < lines.length; i++) {
// No low latency during ads (otherwise it's possible for the player to prefetch and display ad segments)
if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) {
lines[i] = '';
}
}
}
AdSegmentCache.forEach((key, value, map) => {
if (value < Date.now() - 120000) {
map.delete(key);
}
});
postMessage({
key: 'UpdateAdBlockBannerStripping',
isStrippingAdSegments: hasStrippedAdSegments
});
return lines.join('\n');
}
async function processM3U8(url, textStr, realFetch) {
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
//console.log('Unknown stream url ' + url);
//postMessage({key:'UboHideAdBanner'});
const streamInfo = StreamInfosByUrl[url];
if (!streamInfo) {
return textStr;
}
var currentResolution = streamInfo.Urls.get(url);
const 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;
}
const haveAdTags = textStr.includes(AD_SIGNIFIER) || (SimulatedAdsDepth > 0 && (!streamInfo.BackupEncodings || !streamInfo.BackupEncodings.includes(url) || SimulatedAdsDepth - 1 > streamInfo.BackupEncodingsPlayerTypeIndex));
if (streamInfo.BackupEncodings) {
var streamM3u8Url = streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0];
var streamM3u8Response = await realFetch(streamM3u8Url);
const streamM3u8Url = streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0];
const streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status == 200) {
var streamM3u8 = await streamM3u8Response.text();
const streamM3u8 = await streamM3u8Response.text();
if (streamM3u8 != null) {
if (!streamM3u8.includes(AD_SIGNIFIER) && SimulatedAdsDepth == 0) {
console.log('No more ads on main stream. Triggering player reload to go back to main stream...');
@@ -358,9 +408,9 @@ twitch-videoad.js text/javascript
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
} else if (!streamM3u8.includes('"MIDROLL"') && !streamM3u8.includes('"midroll"')) {
var lines = streamM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
const lines = streamM3u8.replaceAll('\r', '').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('#EXTINF') && lines.length > i + 1) {
if (!line.includes(LIVE_SIGNIFIER) && !streamInfo.RequestedAds.has(lines[i + 1])) {
// Only request one .ts file per .m3u8 request to avoid making too many requests
@@ -382,19 +432,34 @@ twitch-videoad.js text/javascript
} else {
postMessage({key:'UboHideAdBanner'});
}
if (IsAdStrippingEnabled) {
textStr = stripAdSegments(textStr);
}
return textStr;
}
function hookWorkerFetch() {
console.log('hookWorkerFetch (video-swap-new)');
var realFetch = fetch;
const realFetch = fetch;
fetch = async function(url, options) {
if (typeof url === 'string') {
if (AdSegmentCache.has(url)) {
return new Promise(function(resolve, reject) {
const send = function() {
return realFetch('data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA', options).then(function(response) {
resolve(response);
})['catch'](function(err) {
reject(err);
});
};
send();
});
}
url = url.trimEnd();
if (url.endsWith('m3u8')) {
return new Promise(function(resolve, reject) {
var processAfter = async function(response) {
const processAfter = async function(response) {
if (response.status === 200) {
var str = await processM3U8(url, await response.text(), realFetch);
const str = await processM3U8(url, await response.text(), realFetch);
resolve(new Response(str, {
status: response.status,
statusText: response.statusText,
@@ -404,7 +469,7 @@ twitch-videoad.js text/javascript
resolve(response);
}
};
var send = function() {
const send = function() {
return realFetch(url, options).then(function(response) {
processAfter(response);
})['catch'](function(err) {
@@ -417,22 +482,22 @@ twitch-videoad.js text/javascript
}
else if (url.includes('/channel/hls/') && !url.includes('picture-by-picture')) {
V2API = url.includes('/api/v2/');
var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
const channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
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);
const tempUrl = new URL(url);
tempUrl.searchParams.delete('parent_domains');
url = tempUrl.toString();
}
return new Promise(async function(resolve, reject) {
// - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc).
// - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads.
var streamInfo = StreamInfos[channelName];
let streamInfo = StreamInfos[channelName];
if (streamInfo != null && streamInfo.Encodings != null && (await realFetch(streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0])).status !== 200) {
// The cached encodings are dead (the stream probably restarted)
streamInfo = null;
}
var serverTime = null;
let serverTime = null;
if (streamInfo == null || streamInfo.Encodings == null) {
StreamInfos[channelName] = streamInfo = {
RequestedAds: new Set(),
@@ -447,16 +512,16 @@ twitch-videoad.js text/javascript
UsherParams: (new URL(url)).search,
Urls: new Map(),
};
var encodingsM3u8Response = await realFetch(url, options);
const encodingsM3u8Response = await realFetch(url, options);
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
var encodingsM3u8 = await encodingsM3u8Response.text();
const encodingsM3u8 = await encodingsM3u8Response.text();
streamInfo.Encodings = encodingsM3u8;
setStreamInfoUrls(streamInfo, encodingsM3u8);
serverTime = getServerTimeFromM3u8(encodingsM3u8);
var resolutionInfo = streamInfo.Urls.values().next().value;
var streamM3u8Response = await realFetch(resolutionInfo.Url);
const resolutionInfo = streamInfo.Urls.values().next().value;
const streamM3u8Response = await realFetch(resolutionInfo.Url);
if (streamM3u8Response.status == 200) {
var streamM3u8 = await streamM3u8Response.text();
const streamM3u8 = await streamM3u8Response.text();
if (streamM3u8.includes(AD_SIGNIFIER) || SimulatedAdsDepth > 0) {
await onFoundAd(streamInfo, streamM3u8, false, realFetch, resolutionInfo.Url, resolutionInfo);
}
@@ -470,7 +535,7 @@ twitch-videoad.js text/javascript
}
}
if (!serverTime) {
var encodingsM3u8Response = await realFetch(url, options);
const encodingsM3u8Response = await realFetch(url, options);
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
serverTime = getServerTimeFromM3u8(await encodingsM3u8Response.text());
}
@@ -485,10 +550,10 @@ twitch-videoad.js text/javascript
}
function getServerTimeFromM3u8(encodingsM3u8) {
if (V2API) {
var matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/);
const matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/);
return matches.length > 1 ? matches[1] : null;
}
var matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"');
const matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"');
return matches.length > 1 ? matches[1] : null;
}
function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) {
@@ -498,17 +563,17 @@ twitch-videoad.js text/javascript
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');
const encodingsLines = encodingsM3u8.replaceAll('\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++) {
let matchedResolutionUrl = null;
let matchedFrameRate = false;
let closestResolutionUrl = null;
let closestResolutionDifference = Infinity;
for (let i = 0; i < encodingsLines.length - 1; i++) {
if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) {
var attributes = parseAttributes(encodingsLines[i]);
var resolution = attributes['RESOLUTION'];
var frameRate = attributes['FRAME-RATE'];
const attributes = parseAttributes(encodingsLines[i]);
const resolution = attributes['RESOLUTION'];
const frameRate = attributes['FRAME-RATE'];
if (resolution) {
if (resolution == resolutionInfo.Resolution && (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) {
matchedResolutionUrl = encodingsLines[i + 1];
@@ -518,7 +583,7 @@ twitch-videoad.js text/javascript
}
}
const [width, height] = resolution.split('x').map(Number);
var difference = Math.abs((width * height) - (targetWidth * targetHeight));
const difference = Math.abs((width * height) - (targetWidth * targetHeight));
if (difference < closestResolutionDifference) {
closestResolutionUrl = encodingsLines[i + 1];
closestResolutionDifference = difference;
@@ -528,21 +593,23 @@ twitch-videoad.js text/javascript
}
return closestResolutionUrl.trimEnd();
}
function getAccessToken(channelName, playerType, platform) {
if (!platform) {
platform = 'web';
}
var body = null;
var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "' + platform + '", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "' + platform + '", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}';
body = {
operationName: 'PlaybackAccessToken_Template',
query: templateQuery,
function getAccessToken(channelName, playerType) {
const realPlayerType = playerType.replace('-ALT', '');
const body = {
operationName: 'PlaybackAccessToken',
variables: {
'isLive': true,
'login': channelName,
'isVod': false,
'vodID': '',
'playerType': playerType
isLive: true,
login: channelName,
isVod: false,
vodID: "",
playerType: realPlayerType,
platform: realPlayerType == 'autoplay' ? 'android' : 'web'
},
extensions: {
persistedQuery: {
version:1,
sha256Hash:"ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9"
}
}
};
return gqlRequest(body, playerType);
@@ -554,14 +621,13 @@ twitch-videoad.js text/javascript
gql_device_id += chars.charAt(Math.floor(Math.random() * chars.length));
}
}
var headers = {
let headers = {
'Client-Id': CLIENT_ID,
'Device-ID': gql_device_id,
'X-Device-Id': gql_device_id,
'Authorization': AuthorizationHeader,
...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader})
};
if (playerType != 'site') {
if (playerType.includes('-ALT')) {
headers = {
'Client-Id': CLIENT_ID,
'X-Device-Id': gql_device_id
@@ -625,12 +691,12 @@ twitch-videoad.js text/javascript
}
}
function hookFetch() {
var realFetch = window.fetch;
const 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'];
let deviceId = init.headers['X-Device-Id'];
if (typeof deviceId !== 'string') {
deviceId = init.headers['Device-ID'];
}
@@ -668,7 +734,7 @@ twitch-videoad.js text/javascript
if (OPT_DISABLE_MATURE_CONTENT_POPUP) {
const newBody2 = JSON.parse(init.body);
if (Array.isArray(newBody2)) {
var hasRemovedClassification = false;
let hasRemovedClassification = false;
for (let i = 0; i < newBody2.length; i++) {
if (newBody2[i]?.operationName == 'ContentClassificationContext') {
hasRemovedClassification = true;
@@ -686,7 +752,7 @@ twitch-videoad.js text/javascript
return realFetch.apply(this, arguments);
};
}
function reloadTwitchPlayer(isSeek, isPausePlay) {
function reloadTwitchPlayer(isPausePlay) {
// Taken from ttv-tools / ffz
// https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts
// https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx
@@ -705,27 +771,27 @@ twitch-videoad.js text/javascript
return null;
}
function findReactRootNode() {
var reactRootNode = null;
var rootNode = document.querySelector('#root');
let reactRootNode = null;
const rootNode = document.querySelector('#root');
if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) {
reactRootNode = rootNode._reactRootContainer._internalRoot.current;
}
if (reactRootNode == null) {
var containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer'));
const containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer'));
if (containerName != null) {
reactRootNode = rootNode[containerName];
}
}
return reactRootNode;
}
var reactRootNode = findReactRootNode();
const reactRootNode = findReactRootNode();
if (!reactRootNode) {
console.log('Could not find react root');
return;
}
var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance);
let 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);
const playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings);
if (!player) {
console.log('Could not find player');
return;
@@ -734,14 +800,7 @@ twitch-videoad.js text/javascript
console.log('Could not find player state');
return;
}
if (player.paused || player.core?.paused) {
return;
}
if (isSeek) {
console.log('Force seek to reset player (hopefully fixing any audio desync) pos:' + player.getPosition() + ' range:' + JSON.stringify(player.getBuffered()));
var pos = player.getPosition();
player.seekTo(0);
player.seekTo(pos);
if (player.isPaused() || player.core?.paused) {
return;
}
if (isPausePlay) {
@@ -752,9 +811,9 @@ twitch-videoad.js text/javascript
const lsKeyQuality = 'video-quality';
const lsKeyMuted = 'video-muted';
const lsKeyVolume = 'volume';
var currentQualityLS = null;
var currentMutedLS = null;
var currentVolumeLS = null;
let currentQualityLS = null;
let currentMutedLS = null;
let currentVolumeLS = null;
try {
currentQualityLS = localStorage.getItem(lsKeyQuality);
currentMutedLS = localStorage.getItem(lsKeyMuted);
@@ -804,13 +863,13 @@ twitch-videoad.js text/javascript
}
});
}catch{}
var block = e => {
const block = e => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
};
let wasVideoPlaying = true;
var visibilityChange = e => {
const visibilityChange = e => {
if (typeof chrome !== 'undefined') {
const videos = document.getElementsByTagName('video');
if (videos.length > 0) {
@@ -844,25 +903,25 @@ twitch-videoad.js text/javascript
}catch{}
// Hooks for preserving volume / resolution
try {
var keysToCache = [
const keysToCache = [
'video-quality',
'video-muted',
'volume',
'lowLatencyModeEnabled',// Low Latency
'persistenceEnabled',// Mini Player
];
var cachedValues = new Map();
for (var i = 0; i < keysToCache.length; i++) {
const cachedValues = new Map();
for (let i = 0; i < keysToCache.length; i++) {
cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i]));
}
var realSetItem = localStorage.setItem;
const realSetItem = localStorage.setItem;
localStorage.setItem = function(key, value) {
if (cachedValues.has(key)) {
cachedValues.set(key, value);
}
realSetItem.apply(this, arguments);
};
var realGetItem = localStorage.getItem;
const realGetItem = localStorage.getItem;
localStorage.getItem = function(key) {
if (cachedValues.has(key)) {
return cachedValues.get(key);
@@ -897,4 +956,7 @@ twitch-videoad.js text/javascript
}
postTwitchWorkerMessage('SimulateAds', depth);
};
window.allSegmentsAreAdSegments = () => {
postTwitchWorkerMessage('AllSegmentsAreAdSegments');
};
})();
+209 -147
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name TwitchAdSolutions (video-swap-new)
// @namespace https://github.com/pixeltris/TwitchAdSolutions
// @version 1.48
// @version 1.49
// @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)
@@ -13,7 +13,7 @@
// ==/UserScript==
(function() {
'use strict';
var ourTwitchAdSolutionsVersion = 16;// Used to prevent conflicts with outdated versions of the scripts
const ourTwitchAdSolutionsVersion = 17;// 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;
@@ -22,8 +22,7 @@
window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
function declareOptions(scope) {
// Options / globals
scope.OPT_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ];
scope.OPT_BACKUP_PLATFORM = 'android';
scope.OPT_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'autoplay-ALT', 'embed' ];
scope.OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE = 'site';
scope.OPT_DISABLE_MATURE_CONTENT_POPUP = false;// If true this avoids having to log in to watch age gated content
scope.AD_SIGNIFIER = 'stitched-ad';
@@ -38,26 +37,29 @@
scope.AuthorizationHeader = undefined;
scope.SimulatedAdsDepth = 0;
scope.V2API = false;
scope.IsAdStrippingEnabled = true;
scope.AdSegmentCache = new Map();
scope.AllSegmentsAreAdSegments = false;
}
var localStorageHookFailed = false;
var adBlockDiv = null;
var twitchWorkers = [];
var workerStringConflicts = [
let localStorageHookFailed = false;
let adBlockDiv = null;
const twitchWorkers = [];
const workerStringConflicts = [
'twitch',
'isVariantA'// TwitchNoSub
];
var workerStringAllow = [];
var workerStringReinsert = [
const workerStringAllow = [];
const workerStringReinsert = [
'isVariantA',// TwitchNoSub (prior to (0.9))
'besuper/',// TwitchNoSub (0.9)
'${patch_url}'// TwitchNoSub (0.9.1)
];
function getCleanWorker(worker) {
var root = null;
var parent = null;
var proto = worker;
let root = null;
let parent = null;
let proto = worker;
while (proto) {
var workerString = proto.toString();
const workerString = proto.toString();
if (workerStringConflicts.some((x) => workerString.includes(x)) && !workerStringAllow.some((x) => workerString.includes(x))) {
if (parent !== null) {
Object.setPrototypeOf(parent, Object.getPrototypeOf(proto));
@@ -73,10 +75,10 @@
return root;
}
function getWorkersForReinsert(worker) {
var result = [];
var proto = worker;
const result = [];
let proto = worker;
while (proto) {
var workerString = proto.toString();
const workerString = proto.toString();
if (workerStringReinsert.some((x) => workerString.includes(x))) {
result.push(proto);
} else {
@@ -86,24 +88,24 @@
return result;
}
function reinsertWorkers(worker, reinsert) {
var parent = worker;
for (var i = 0; i < reinsert.length; i++) {
let parent = worker;
for (let i = 0; i < reinsert.length; i++) {
Object.setPrototypeOf(reinsert[i], parent);
parent = reinsert[i];
}
return parent;
}
function isValidWorker(worker) {
var workerString = worker.toString();
const workerString = worker.toString();
return !workerStringConflicts.some((x) => workerString.includes(x))
|| workerStringAllow.some((x) => workerString.includes(x))
|| workerStringReinsert.some((x) => workerString.includes(x));
}
function hookWindowWorker() {
var reinsert = getWorkersForReinsert(window.Worker);
var newWorker = class Worker extends getCleanWorker(window.Worker) {
const reinsert = getWorkersForReinsert(window.Worker);
const newWorker = class Worker extends getCleanWorker(window.Worker) {
constructor(twitchBlobUrl, options) {
var isTwitchWorker = false;
let isTwitchWorker = false;
try {
isTwitchWorker = new URL(twitchBlobUrl).origin.endsWith('.twitch.tv');
} catch {}
@@ -111,8 +113,9 @@
super(twitchBlobUrl, options);
return;
}
var newBlobStr = `
const newBlobStr = `
const pendingFetchRequests = new Map();
${stripAdSegments.toString()}
${processM3U8.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
@@ -125,7 +128,7 @@
${getServerTimeFromM3u8.toString()}
${replaceServerTimeInM3u8.toString()}
${getStreamUrlForResolution.toString()}
var workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}');
const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}');
declareOptions(self);
gql_device_id = ${gql_device_id ? "'" + gql_device_id + "'" : null};
AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined};
@@ -156,7 +159,10 @@
}
} else if (e.data.key == 'SimulateAds') {
SimulatedAdsDepth = e.data.value;
console.log('SimulatedAdsDepth:' + SimulatedAdsDepth);
console.log('SimulatedAdsDepth: ' + SimulatedAdsDepth);
} else if (e.data.key == 'AllSegmentsAreAdSegments') {
AllSegmentsAreAdSegments = !AllSegmentsAreAdSegments;
console.log('AllSegmentsAreAdSegments: ' + AllSegmentsAreAdSegments);
}
});
hookWorkerFetch();
@@ -166,13 +172,24 @@
twitchWorkers.push(this);
this.addEventListener('message', (e) => {
// NOTE: Removed adDiv caching as '.video-player' can change between streams?
if (e.data.key == 'UboShowAdBanner') {
if (e.data.key == 'UboShowAdBanner' || e.data.key == 'UpdateAdBlockBannerStripping') {
if (adBlockDiv == null) {
adBlockDiv = getAdDiv();
}
if (adBlockDiv != null) {
adBlockDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads';
adBlockDiv.style.display = 'block';
if (e.data.key == 'UpdateAdBlockBannerStripping') {
const strippingAdsSuffix = ' (stripping)';
if (adBlockDiv.P.textContent.endsWith(strippingAdsSuffix)) {
if (!e.data.isStrippingAdSegments) {
adBlockDiv.P.textContent = adBlockDiv.P.textContent.substring(0, adBlockDiv.P.textContent.length - strippingAdsSuffix.length);
}
} else if (e.data.isStrippingAdSegments) {
adBlockDiv.P.textContent += strippingAdsSuffix;
}
} else {
adBlockDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads';
adBlockDiv.style.display = 'block';
}
}
} else if (e.data.key == 'UboHideAdBanner') {
if (adBlockDiv == null) {
@@ -182,7 +199,7 @@
adBlockDiv.style.display = 'none';
}
} else if (e.data.key == 'UboReloadPlayer') {
reloadTwitchPlayer();
reloadTwitchPlayer(false);
} else if (e.data.key == 'UboPauseResumePlayer') {
reloadTwitchPlayer(true);
}
@@ -198,8 +215,8 @@
}
});
function getAdDiv() {
var playerRootDiv = document.querySelector('.video-player');
var adDiv = null;
const playerRootDiv = document.querySelector('.video-player');
let adDiv = null;
if (playerRootDiv != null) {
adDiv = playerRootDiv.querySelector('.ubo-overlay');
if (adDiv == null) {
@@ -215,7 +232,7 @@
}
}
}
var workerInstance = reinsertWorkers(newWorker, reinsert);
let workerInstance = reinsertWorkers(newWorker, reinsert);
Object.defineProperty(window, 'Worker', {
get: function() {
return workerInstance;
@@ -230,23 +247,23 @@
});
}
function getWasmWorkerJs(twitchBlobUrl) {
var req = new XMLHttpRequest();
const req = new XMLHttpRequest();
req.open('GET', twitchBlobUrl, false);
req.overrideMimeType("text/javascript");
req.send();
return req.responseText;
}
function setStreamInfoUrls(streamInfo, encodingsM3u8) {
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
const lines = encodingsM3u8.replaceAll('\r', '').split('\n');
for (let 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'];
const attributes = parseAttributes(lines[i]);
const resolution = attributes['RESOLUTION'];
if (resolution) {
var resolutionInfo = {
const resolutionInfo = {
Resolution: resolution,
FrameRate: attributes['FRAME-RATE'],
Url: lines[i + 1]
@@ -257,37 +274,37 @@
}
}
async function onFoundAd(streamInfo, textStr, reloadPlayer, realFetch, url, resolutionInfo) {
var result = textStr;
let result = textStr;
streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"');
var playerTypes = OPT_BACKUP_PLAYER_TYPES;
const playerTypes = OPT_BACKUP_PLAYER_TYPES;
if (streamInfo.BackupEncodingsStatus.size >= playerTypes.length) {
return textStr;
}
if (streamInfo.BackupEncodings && !streamInfo.BackupEncodings.includes(url)) {
var streamM3u8Url = getStreamUrlForResolution(streamInfo.BackupEncodings, resolutionInfo);
var streamM3u8Response = await realFetch(streamM3u8Url);
const streamM3u8Url = getStreamUrlForResolution(streamInfo.BackupEncodings, resolutionInfo);
const streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status === 200) {
return await streamM3u8Response.text();
}
}
var backupPlayerTypeInfo = '';
for (var i = 0; i < playerTypes.length; i++) {
var playerType = playerTypes[i];
let backupPlayerTypeInfo = '';
for (let i = 0; i < playerTypes.length; i++) {
const playerType = playerTypes[i];
if (!streamInfo.BackupEncodingsStatus.has(playerType)) {
try {
var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, playerType, OPT_BACKUP_PLATFORM);
const accessTokenResponse = await getAccessToken(streamInfo.ChannelName, playerType);
if (accessTokenResponse != null && accessTokenResponse.status === 200) {
var accessToken = await accessTokenResponse.json();
var urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams);
const accessToken = await accessTokenResponse.json();
const urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams);
urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
var encodingsM3u8Response = await realFetch(urlInfo.href);
const encodingsM3u8Response = await realFetch(urlInfo.href);
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
var encodingsM3u8 = await encodingsM3u8Response.text();
var streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, resolutionInfo);
var streamM3u8Response = await realFetch(streamM3u8Url);
let encodingsM3u8 = await encodingsM3u8Response.text();
const streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, resolutionInfo);
const streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status === 200) {
var backTextStr = await streamM3u8Response.text();
const backTextStr = await streamM3u8Response.text();
if ((!backTextStr.includes(AD_SIGNIFIER) && (SimulatedAdsDepth == 0 || i >= SimulatedAdsDepth - 1)) || i >= playerTypes.length - 1) {
result = backTextStr;
backupPlayerTypeInfo = ' (' + playerType + ')';
@@ -295,14 +312,14 @@
streamInfo.BackupEncodingsPlayerTypeIndex = i;
if (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++) {
const normalEncodingsM3u8 = streamInfo.Encodings;
const normalLines = normalEncodingsM3u8.replaceAll('\r', '').split('\n');
for (let 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));
const resSettings = parseAttributes(normalLines[j].substring(normalLines[j].indexOf(':') + 1));
const lowResUrl = getStreamUrlForResolution(encodingsM3u8, streamInfo.Urls.get(normalLines[j + 1].trimEnd()));
const lowResInf = encodingsM3u8.match(new RegExp(`^.*(?=\n.*${lowResUrl})`, 'm'))[0];
const 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' &&
@@ -340,26 +357,59 @@
postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll});
return result;
}
function stripAdSegments(textStr, stripAllSegments) {
let hasStrippedAdSegments = false;
const lines = textStr.replaceAll('\r', '').split('\n');
const newAdUrl = 'https://twitch.tv';
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Remove tracking urls which appear in the overlay UI
line = line
.replaceAll(/(X-TV-TWITCH-AD-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`)
.replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`);
if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) {
const segmentUrl = lines[i + 1];
AdSegmentCache.set(segmentUrl, Date.now());
hasStrippedAdSegments = true;
}
if (line.includes(AD_SIGNIFIER)) {
hasStrippedAdSegments = true;
}
}
if (hasStrippedAdSegments) {
for (let i = 0; i < lines.length; i++) {
// No low latency during ads (otherwise it's possible for the player to prefetch and display ad segments)
if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) {
lines[i] = '';
}
}
}
AdSegmentCache.forEach((key, value, map) => {
if (value < Date.now() - 120000) {
map.delete(key);
}
});
postMessage({
key: 'UpdateAdBlockBannerStripping',
isStrippingAdSegments: hasStrippedAdSegments
});
return lines.join('\n');
}
async function processM3U8(url, textStr, realFetch) {
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
//console.log('Unknown stream url ' + url);
//postMessage({key:'UboHideAdBanner'});
const streamInfo = StreamInfosByUrl[url];
if (!streamInfo) {
return textStr;
}
var currentResolution = streamInfo.Urls.get(url);
const 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;
}
const haveAdTags = textStr.includes(AD_SIGNIFIER) || (SimulatedAdsDepth > 0 && (!streamInfo.BackupEncodings || !streamInfo.BackupEncodings.includes(url) || SimulatedAdsDepth - 1 > streamInfo.BackupEncodingsPlayerTypeIndex));
if (streamInfo.BackupEncodings) {
var streamM3u8Url = streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0];
var streamM3u8Response = await realFetch(streamM3u8Url);
const streamM3u8Url = streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0];
const streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status == 200) {
var streamM3u8 = await streamM3u8Response.text();
const streamM3u8 = await streamM3u8Response.text();
if (streamM3u8 != null) {
if (!streamM3u8.includes(AD_SIGNIFIER) && SimulatedAdsDepth == 0) {
console.log('No more ads on main stream. Triggering player reload to go back to main stream...');
@@ -370,9 +420,9 @@
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
} else if (!streamM3u8.includes('"MIDROLL"') && !streamM3u8.includes('"midroll"')) {
var lines = streamM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
const lines = streamM3u8.replaceAll('\r', '').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('#EXTINF') && lines.length > i + 1) {
if (!line.includes(LIVE_SIGNIFIER) && !streamInfo.RequestedAds.has(lines[i + 1])) {
// Only request one .ts file per .m3u8 request to avoid making too many requests
@@ -394,19 +444,34 @@
} else {
postMessage({key:'UboHideAdBanner'});
}
if (IsAdStrippingEnabled) {
textStr = stripAdSegments(textStr);
}
return textStr;
}
function hookWorkerFetch() {
console.log('hookWorkerFetch (video-swap-new)');
var realFetch = fetch;
const realFetch = fetch;
fetch = async function(url, options) {
if (typeof url === 'string') {
if (AdSegmentCache.has(url)) {
return new Promise(function(resolve, reject) {
const send = function() {
return realFetch('data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA', options).then(function(response) {
resolve(response);
})['catch'](function(err) {
reject(err);
});
};
send();
});
}
url = url.trimEnd();
if (url.endsWith('m3u8')) {
return new Promise(function(resolve, reject) {
var processAfter = async function(response) {
const processAfter = async function(response) {
if (response.status === 200) {
var str = await processM3U8(url, await response.text(), realFetch);
const str = await processM3U8(url, await response.text(), realFetch);
resolve(new Response(str, {
status: response.status,
statusText: response.statusText,
@@ -416,7 +481,7 @@
resolve(response);
}
};
var send = function() {
const send = function() {
return realFetch(url, options).then(function(response) {
processAfter(response);
})['catch'](function(err) {
@@ -429,22 +494,22 @@
}
else if (url.includes('/channel/hls/') && !url.includes('picture-by-picture')) {
V2API = url.includes('/api/v2/');
var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
const channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
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);
const tempUrl = new URL(url);
tempUrl.searchParams.delete('parent_domains');
url = tempUrl.toString();
}
return new Promise(async function(resolve, reject) {
// - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc).
// - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads.
var streamInfo = StreamInfos[channelName];
let streamInfo = StreamInfos[channelName];
if (streamInfo != null && streamInfo.Encodings != null && (await realFetch(streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0])).status !== 200) {
// The cached encodings are dead (the stream probably restarted)
streamInfo = null;
}
var serverTime = null;
let serverTime = null;
if (streamInfo == null || streamInfo.Encodings == null) {
StreamInfos[channelName] = streamInfo = {
RequestedAds: new Set(),
@@ -459,16 +524,16 @@
UsherParams: (new URL(url)).search,
Urls: new Map(),
};
var encodingsM3u8Response = await realFetch(url, options);
const encodingsM3u8Response = await realFetch(url, options);
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
var encodingsM3u8 = await encodingsM3u8Response.text();
const encodingsM3u8 = await encodingsM3u8Response.text();
streamInfo.Encodings = encodingsM3u8;
setStreamInfoUrls(streamInfo, encodingsM3u8);
serverTime = getServerTimeFromM3u8(encodingsM3u8);
var resolutionInfo = streamInfo.Urls.values().next().value;
var streamM3u8Response = await realFetch(resolutionInfo.Url);
const resolutionInfo = streamInfo.Urls.values().next().value;
const streamM3u8Response = await realFetch(resolutionInfo.Url);
if (streamM3u8Response.status == 200) {
var streamM3u8 = await streamM3u8Response.text();
const streamM3u8 = await streamM3u8Response.text();
if (streamM3u8.includes(AD_SIGNIFIER) || SimulatedAdsDepth > 0) {
await onFoundAd(streamInfo, streamM3u8, false, realFetch, resolutionInfo.Url, resolutionInfo);
}
@@ -482,7 +547,7 @@
}
}
if (!serverTime) {
var encodingsM3u8Response = await realFetch(url, options);
const encodingsM3u8Response = await realFetch(url, options);
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
serverTime = getServerTimeFromM3u8(await encodingsM3u8Response.text());
}
@@ -497,10 +562,10 @@
}
function getServerTimeFromM3u8(encodingsM3u8) {
if (V2API) {
var matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/);
const matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/);
return matches.length > 1 ? matches[1] : null;
}
var matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"');
const matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"');
return matches.length > 1 ? matches[1] : null;
}
function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) {
@@ -510,17 +575,17 @@
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');
const encodingsLines = encodingsM3u8.replaceAll('\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++) {
let matchedResolutionUrl = null;
let matchedFrameRate = false;
let closestResolutionUrl = null;
let closestResolutionDifference = Infinity;
for (let i = 0; i < encodingsLines.length - 1; i++) {
if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) {
var attributes = parseAttributes(encodingsLines[i]);
var resolution = attributes['RESOLUTION'];
var frameRate = attributes['FRAME-RATE'];
const attributes = parseAttributes(encodingsLines[i]);
const resolution = attributes['RESOLUTION'];
const frameRate = attributes['FRAME-RATE'];
if (resolution) {
if (resolution == resolutionInfo.Resolution && (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) {
matchedResolutionUrl = encodingsLines[i + 1];
@@ -530,7 +595,7 @@
}
}
const [width, height] = resolution.split('x').map(Number);
var difference = Math.abs((width * height) - (targetWidth * targetHeight));
const difference = Math.abs((width * height) - (targetWidth * targetHeight));
if (difference < closestResolutionDifference) {
closestResolutionUrl = encodingsLines[i + 1];
closestResolutionDifference = difference;
@@ -540,21 +605,23 @@
}
return closestResolutionUrl.trimEnd();
}
function getAccessToken(channelName, playerType, platform) {
if (!platform) {
platform = 'web';
}
var body = null;
var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "' + platform + '", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "' + platform + '", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}';
body = {
operationName: 'PlaybackAccessToken_Template',
query: templateQuery,
function getAccessToken(channelName, playerType) {
const realPlayerType = playerType.replace('-ALT', '');
const body = {
operationName: 'PlaybackAccessToken',
variables: {
'isLive': true,
'login': channelName,
'isVod': false,
'vodID': '',
'playerType': playerType
isLive: true,
login: channelName,
isVod: false,
vodID: "",
playerType: realPlayerType,
platform: realPlayerType == 'autoplay' ? 'android' : 'web'
},
extensions: {
persistedQuery: {
version:1,
sha256Hash:"ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9"
}
}
};
return gqlRequest(body, playerType);
@@ -566,14 +633,13 @@
gql_device_id += chars.charAt(Math.floor(Math.random() * chars.length));
}
}
var headers = {
let headers = {
'Client-Id': CLIENT_ID,
'Device-ID': gql_device_id,
'X-Device-Id': gql_device_id,
'Authorization': AuthorizationHeader,
...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader})
};
if (playerType != 'site') {
if (playerType.includes('-ALT')) {
headers = {
'Client-Id': CLIENT_ID,
'X-Device-Id': gql_device_id
@@ -637,12 +703,12 @@
}
}
function hookFetch() {
var realFetch = window.fetch;
const 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'];
let deviceId = init.headers['X-Device-Id'];
if (typeof deviceId !== 'string') {
deviceId = init.headers['Device-ID'];
}
@@ -680,7 +746,7 @@
if (OPT_DISABLE_MATURE_CONTENT_POPUP) {
const newBody2 = JSON.parse(init.body);
if (Array.isArray(newBody2)) {
var hasRemovedClassification = false;
let hasRemovedClassification = false;
for (let i = 0; i < newBody2.length; i++) {
if (newBody2[i]?.operationName == 'ContentClassificationContext') {
hasRemovedClassification = true;
@@ -698,7 +764,7 @@
return realFetch.apply(this, arguments);
};
}
function reloadTwitchPlayer(isSeek, isPausePlay) {
function reloadTwitchPlayer(isPausePlay) {
// Taken from ttv-tools / ffz
// https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts
// https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx
@@ -717,27 +783,27 @@
return null;
}
function findReactRootNode() {
var reactRootNode = null;
var rootNode = document.querySelector('#root');
let reactRootNode = null;
const rootNode = document.querySelector('#root');
if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) {
reactRootNode = rootNode._reactRootContainer._internalRoot.current;
}
if (reactRootNode == null) {
var containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer'));
const containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer'));
if (containerName != null) {
reactRootNode = rootNode[containerName];
}
}
return reactRootNode;
}
var reactRootNode = findReactRootNode();
const reactRootNode = findReactRootNode();
if (!reactRootNode) {
console.log('Could not find react root');
return;
}
var player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance);
let 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);
const playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings);
if (!player) {
console.log('Could not find player');
return;
@@ -746,14 +812,7 @@
console.log('Could not find player state');
return;
}
if (player.paused || player.core?.paused) {
return;
}
if (isSeek) {
console.log('Force seek to reset player (hopefully fixing any audio desync) pos:' + player.getPosition() + ' range:' + JSON.stringify(player.getBuffered()));
var pos = player.getPosition();
player.seekTo(0);
player.seekTo(pos);
if (player.isPaused() || player.core?.paused) {
return;
}
if (isPausePlay) {
@@ -764,9 +823,9 @@
const lsKeyQuality = 'video-quality';
const lsKeyMuted = 'video-muted';
const lsKeyVolume = 'volume';
var currentQualityLS = null;
var currentMutedLS = null;
var currentVolumeLS = null;
let currentQualityLS = null;
let currentMutedLS = null;
let currentVolumeLS = null;
try {
currentQualityLS = localStorage.getItem(lsKeyQuality);
currentMutedLS = localStorage.getItem(lsKeyMuted);
@@ -816,13 +875,13 @@
}
});
}catch{}
var block = e => {
const block = e => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
};
let wasVideoPlaying = true;
var visibilityChange = e => {
const visibilityChange = e => {
if (typeof chrome !== 'undefined') {
const videos = document.getElementsByTagName('video');
if (videos.length > 0) {
@@ -856,25 +915,25 @@
}catch{}
// Hooks for preserving volume / resolution
try {
var keysToCache = [
const keysToCache = [
'video-quality',
'video-muted',
'volume',
'lowLatencyModeEnabled',// Low Latency
'persistenceEnabled',// Mini Player
];
var cachedValues = new Map();
for (var i = 0; i < keysToCache.length; i++) {
const cachedValues = new Map();
for (let i = 0; i < keysToCache.length; i++) {
cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i]));
}
var realSetItem = localStorage.setItem;
const realSetItem = localStorage.setItem;
localStorage.setItem = function(key, value) {
if (cachedValues.has(key)) {
cachedValues.set(key, value);
}
realSetItem.apply(this, arguments);
};
var realGetItem = localStorage.getItem;
const realGetItem = localStorage.getItem;
localStorage.getItem = function(key) {
if (cachedValues.has(key)) {
return cachedValues.get(key);
@@ -909,4 +968,7 @@
}
postTwitchWorkerMessage('SimulateAds', depth);
};
window.allSegmentsAreAdSegments = () => {
postTwitchWorkerMessage('AllSegmentsAreAdSegments');
};
})();