vaft/video-swap-new improvements

- vaft fixed 2k/4k stream error 4000/3000 #451
- vaft added integrated buffering fix using pause/play during ads #404 #164
- video-swap-new fixed 160p being used as low res instead of 360p
- vaft/video-swap-new removed unneeded GM.xmlHttpRequest #399
- vaft/video-swap-new improved check for unpausing muted stream when switching  tab #442
- vaft/video-swap-new some adjustments to saving player state under chrome based browsers #329
- vaft reduce number of ads when the player is embeded

Brave needs to be on 1.81.131 (released Aug 6 2025) or later otherwise ad blocking will fail https://community.brave.app/t/release-channel-1-81-131/637972
This commit is contained in:
pixeltris
2025-11-01 17:01:06 +00:00
parent 9b616aef9b
commit 598cfb209c
6 changed files with 1282 additions and 1709 deletions
+12 -3
View File
@@ -43,8 +43,17 @@ To stop using a script remove the filter and make the url `unset`.
*The scripts __may randomly stop being applied by uBlock Origin__ for unknown reasons ([#200](https://github.com/pixeltris/TwitchAdSolutions/issues/200)). It's recommended to use the userscript versions instead.*
**Brave currently doesn't work with the uBlock Origin scripts, use the userscript instead.**
## Applying a script (userscript)
- Viewing one of the userscript files should prompt the given script to be added (assuming you have a userscript manager).
Viewing one of the userscript files should prompt the given script to be added when you have a userscript manager installed.
Userscript managers:
- https://violentmonkey.github.io/
- https://www.tampermonkey.net/
- https://addons.mozilla.org/en-US/firefox/addon/greasemonkey/
- https://apps.apple.com/us/app/userscripts/id1463298887
## Issues with the scripts
If the script doesn't work or you're experiencing freezing / buffering issues see [issues.md](issues.md)
+38
View File
@@ -0,0 +1,38 @@
# Issues with `vaft` / `video-swap-new`
## Neither script works
If you're using the uBlock Origin version of the script you need to make sure that it's set up correctly based on the instructions in the README. Check the script is active by opening your browsers developer console, refreshing a stream, and searching for `hookWorkerFetch (vaft)` / `hookWorkerFetch (video-swap-new)`. If you don't see this, then the script isn't being injected and you need to find the reason why.
## Streams sometimes appear offline when ads occur
This needs to be fixed but currently the exact cause is unknown.
## `vaft`
### Freezing / buffering
The stream may have playback problems during ads. There is an integrated solution that attempts to automatically press pause/play if buffering occurs during ads. If this isn't working you could try changing `AlwaysReloadPlayerOnAd` from `false` to `true` which will trigger a player reload as it enters/leaves ads which may add some stability. If neither of those are working how you'd like you can disable the pause/play buffer fixing attempt by modifying the userscript and change `FixPlayerBufferingInsideAds` from `true` to `false`. You then need to find an alternative solution to the buffering such as the folloing:
- https://github.com/pixeltris/TwitchAdSolutions/issues/299#issuecomment-2509500697
- https://github.com/pixeltris/TwitchAdSolutions/issues/313
If you're still having problems it's recommended to switch to using `video-swap-new` (which is the recommended script in the README).
### Audio desyncs
This script can be susceptible to audio desyncs due to the way that it works. There currently isn't any fix for this. Try a different script / solution.
### Freezing / buffering after ads
You can try changing `FixPlayerBufferingOutsideAds` from `false` to `true` which will scan the player for buffering when ads aren't happening and will trigger a pause/play which may work for you.
## `video-swap-new`
### Long black screen during ads
The script reloads the player as it enters/leaves ads to switch the active m3u8 file that is being used. On some systems this may cause a long period of time where the player is black as the player is loading back in. There currently isn't any fix for this. Try a different script / solution. *This also applies to `vaft` where the streamer has a 2k/4k quality setting as a player reload is required to handle these.*
### Freezing / buffering during ads
Generally `video-swap-new` should be less susceptible to buffering / freezing than `vaft` as all it does is switch the active m3u8 file as it enters / leaves ads. There currently isn't a fix for freezing / buffering issues for `video-swap-new`. Try a different script / solution.
+446 -631
View File
File diff suppressed because it is too large Load Diff
+448 -634
View File
File diff suppressed because it is too large Load Diff
+168 -219
View File
@@ -1,37 +1,32 @@
twitch-videoad.js text/javascript
(function() {
if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; }
var ourTwitchAdSolutionsVersion = 13;// Used to prevent conflicts with outdated versions of the scripts
if (typeof unsafeWindow === 'undefined') {
unsafeWindow = window;
}
if (typeof unsafeWindow.twitchAdSolutionsVersion !== 'undefined' && unsafeWindow.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) {
console.log("skipping video-swap-new as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + unsafeWindow.twitchAdSolutionsVersion);
unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
var ourTwitchAdSolutionsVersion = 14;// Used to prevent conflicts with outdated versions of the scripts
if (typeof window.twitchAdSolutionsVersion !== 'undefined' && window.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) {
console.log("skipping video-swap-new as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + window.twitchAdSolutionsVersion);
window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
return;
}
unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
function declareOptions(scope) {
// Options / globals
scope.OPT_PREROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ];
scope.OPT_MIDROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ];
scope.OPT_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ];
scope.OPT_BACKUP_PLATFORM = 'android';
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site';
scope.OPT_STRIP_PARENT_DOMAINS = true;
scope.OPT_SHOW_AD_BANNER = true;
scope.OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE = 'site';
scope.AD_SIGNIFIER = 'stitched-ad';
scope.LIVE_SIGNIFIER = ',live';
scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
// These are only really for Worker scope...
scope.StreamInfos = [];
scope.StreamInfosByUrl = [];
scope.CurrentChannelNameFromM3U8 = null;
// Need this in both scopes. Window scope needs to update this to worker scope.
scope.gql_device_id = null;
scope.ClientIntegrityHeader = null;
scope.AuthorizationHeader = null;
scope.AuthorizationHeader = undefined;
scope.SimulatedAdsDepth = 0;
}
var localStorageHookFailed = false;
var adBlockDiv = null;
var twitchWorkers = [];
var workerStringConflicts = [
'twitch',
@@ -91,8 +86,8 @@ twitch-videoad.js text/javascript
|| workerStringReinsert.some((x) => workerString.includes(x));
}
function hookWindowWorker() {
var reinsert = getWorkersForReinsert(unsafeWindow.Worker);
var newWorker = class Worker extends getCleanWorker(unsafeWindow.Worker) {
var reinsert = getWorkersForReinsert(window.Worker);
var newWorker = class Worker extends getCleanWorker(window.Worker) {
constructor(twitchBlobUrl, options) {
var isTwitchWorker = false;
try {
@@ -109,18 +104,18 @@ twitch-videoad.js text/javascript
${declareOptions.toString()}
${getAccessToken.toString()}
${gqlRequest.toString()}
${makeGraphQlPacket.toString()}
${parseAttributes.toString()}
${setStreamInfoUrls.toString()}
${onFoundAd.toString()}
${getWasmWorkerJs.toString()}
${getServerTimeFromM3u8.toString()}
${replaceServerTimeInM3u8.toString()}
${getStreamUrlForResolution.toString()}
var workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}');
declareOptions(self);
gql_device_id = ${gql_device_id ? "'" + gql_device_id + "'" : null};
AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : null};
ClientIntegrityHeader = ${AuthorizationHeader ? "'" + ClientIntegrityHeader + "'" : null};
AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined};
ClientIntegrityHeader = ${ClientIntegrityHeader ? "'" + ClientIntegrityHeader + "'" : null};
self.addEventListener('message', function(e) {
if (e.data.key == 'UboUpdateDeviceId') {
gql_device_id = e.data.value;
@@ -158,25 +153,23 @@ twitch-videoad.js text/javascript
this.addEventListener('message', (e) => {
// NOTE: Removed adDiv caching as '.video-player' can change between streams?
if (e.data.key == 'UboShowAdBanner') {
var adDiv = getAdDiv();
if (adDiv != null) {
adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads';
if (OPT_SHOW_AD_BANNER) {
adDiv.style.display = 'block';
}
if (adBlockDiv == null) {
adBlockDiv = getAdDiv();
}
if (adBlockDiv != null) {
adBlockDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads';
adBlockDiv.style.display = 'block';
}
} else if (e.data.key == 'UboHideAdBanner') {
var adDiv = getAdDiv();
if (adDiv != null) {
adDiv.style.display = 'none';
if (adBlockDiv == null) {
adBlockDiv = getAdDiv();
}
if (adBlockDiv != null) {
adBlockDiv.style.display = 'none';
}
} else if (e.data.key == 'UboChannelNameM3U8Changed') {
//console.log('M3U8 channel name changed to ' + e.data.value);
} else if (e.data.key == 'UboReloadPlayer') {
reloadTwitchPlayer();
} else if (e.data.key == 'UboPauseResumePlayer') {
reloadTwitchPlayer(false, true);
} else if (e.data.key == 'UboSeekPlayer') {
reloadTwitchPlayer(true);
}
});
@@ -209,7 +202,7 @@ twitch-videoad.js text/javascript
}
}
var workerInstance = reinsertWorkers(newWorker, reinsert);
Object.defineProperty(unsafeWindow, 'Worker', {
Object.defineProperty(window, 'Worker', {
get: function() {
return workerInstance;
},
@@ -231,22 +224,33 @@ twitch-videoad.js text/javascript
}
function setStreamInfoUrls(streamInfo, encodingsM3u8) {
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var j = 0; j < lines.length; j++) {
if (!lines[j].startsWith('#') && lines[j].includes('.m3u8')) {
StreamInfosByUrl[lines[j].trimEnd()] = streamInfo;
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
StreamInfosByUrl[lines[i].trimEnd()] = streamInfo;
}
if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) {
var attributes = parseAttributes(lines[i]);
var resolution = attributes['RESOLUTION'];
if (resolution) {
var resolutionInfo = {
Resolution: resolution,
FrameRate: attributes['FRAME-RATE'],
Url: lines[i + 1]
};
streamInfo.Urls.set(lines[i + 1].trimEnd(), resolutionInfo);
}
}
}
}
async function onFoundAd(streamInfo, textStr, reloadPlayer, realFetch, url) {
async function onFoundAd(streamInfo, textStr, reloadPlayer, realFetch, url, resolutionInfo) {
var result = textStr;
streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"');
var playerTypes = streamInfo.IsMidroll ? OPT_MIDROLL_BACKUP_PLAYER_TYPES : OPT_PREROLL_BACKUP_PLAYER_TYPES;
var playerTypes = OPT_BACKUP_PLAYER_TYPES;
if (streamInfo.BackupEncodingsStatus.size >= playerTypes.length) {
return textStr;
}
if (streamInfo.BackupEncodings && !streamInfo.BackupEncodings.includes(url)) {
// NOTE: This might cause some freezing issues
var streamM3u8Url = streamInfo.BackupEncodings.match(/^https:.*\.m3u8.*$/m)[0];
var streamM3u8Url = getStreamUrlForResolution(streamInfo.BackupEncodings, resolutionInfo);
var streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status === 200) {
return await streamM3u8Response.text();
@@ -266,7 +270,7 @@ twitch-videoad.js text/javascript
var encodingsM3u8Response = await realFetch(urlInfo.href);
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
var encodingsM3u8 = await encodingsM3u8Response.text();
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8.*$/m)[0];
var streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, resolutionInfo);
var streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status === 200) {
var backTextStr = await streamM3u8Response.text();
@@ -275,48 +279,31 @@ twitch-videoad.js text/javascript
backupPlayerTypeInfo = ' (' + playerType + ')';
streamInfo.BackupEncodingsStatus.set(playerType, 1);
streamInfo.BackupEncodingsPlayerTypeIndex = i;
if (playerType !== 'embed') {
// Low resolution streams will reduce the number of resolutions in the UI. To fix this we merge the highest low res into the main m3u8
// TODO: Do a better matching up of the resolutions rather than picking the highest low res for all
var lowResLines = encodingsM3u8.replace('\r', '').split('\n');
var lowResBestUrl = null;
var lowResSettings = [];
for (var j = 0; j < lowResLines.length; j++) {
if (lowResLines[j].startsWith('#EXT-X-STREAM-INF')) {
var res = parseAttributes(lowResLines[j])['RESOLUTION'];
if (res && lowResLines[j + 1].endsWith('.m3u8')) {
// Assumes resolutions are correctly ordered
lowResBestUrl = lowResLines[j + 1];
lowResSettings = parseAttributes(lowResLines[j].substring(lowResLines[j].indexOf(':') + 1));
break;
if (playerType !== 'embed' && streamInfo.Encodings != null) {
// Low resolution streams will reduce the number of resolutions in the UI. To fix this we merge the low res URLs into the main m3u8
var normalEncodingsM3u8 = streamInfo.Encodings;
var normalLines = normalEncodingsM3u8.replace('\r', '').split('\n');
for (var j = 0; j < normalLines.length - 1; j++) {
if (normalLines[j].startsWith('#EXT-X-STREAM-INF')) {
var resSettings = parseAttributes(normalLines[j].substring(normalLines[j].indexOf(':') + 1));
var lowResUrl = getStreamUrlForResolution(encodingsM3u8, streamInfo.Urls.get(normalLines[j + 1].trimEnd()));
var lowResInf = encodingsM3u8.match(new RegExp(`^.*(?=\n.*${lowResUrl})`, 'm'))[0];
var lowResSettings = parseAttributes(lowResInf.substring(lowResInf.indexOf(':') + 1));
//console.log('map ' + resSettings['RESOLUTION'] + ' to ' + lowResSettings['RESOLUTION']);
const codecsKey = 'CODECS';
if (typeof resSettings[codecsKey] === 'string' && typeof lowResSettings[codecsKey] === 'string' &&
resSettings[codecsKey].length >= 3 && lowResSettings[codecsKey].length >= 3 &&
(resSettings[codecsKey].startsWith('hev') || resSettings[codecsKey].startsWith('hvc')) &&
resSettings[codecsKey].substring(0, 3) !== lowResSettings[codecsKey].substring(0, 3)
) {
console.log('swap ' + resSettings[codecsKey] + ' to ' + lowResSettings[codecsKey]);
normalLines[j] = normalLines[j].replace(/CODECS="[^"]+"/, `CODECS="${lowResSettings[codecsKey]}"`);
console.log(normalLines[j]);
}
normalLines[j + 1] = lowResUrl + ' '.repeat(j + 1);// The stream doesn't load unless each url line is unique
}
}
if (lowResBestUrl != null && streamInfo.Encodings != null) {
var normalEncodingsM3u8 = streamInfo.Encodings;
var normalLines = normalEncodingsM3u8.replace('\r', '').split('\n');
for (var j = 0; j < normalLines.length - 1; j++) {
if (normalLines[j].startsWith('#EXT-X-STREAM-INF')) {
var resSettings = parseAttributes(normalLines[j].substring(normalLines[j].indexOf(':') + 1));
const codecsKey = 'CODECS';
if (typeof resSettings[codecsKey] === 'string' && typeof lowResSettings[codecsKey] === 'string' &&
resSettings[codecsKey].length >= 3 && lowResSettings[codecsKey].length >= 3 &&
resSettings[codecsKey].substring(0, 3) === 'hev' &&
resSettings[codecsKey].substring(0, 3) !== lowResSettings[codecsKey].substring(0, 3)
) {
console.log('swap ' + resSettings[codecsKey] + ' to ' + lowResSettings[codecsKey]);
normalLines[j] = normalLines[j].replace(/CODECS="[^"]+"/, `CODECS="${lowResSettings[codecsKey]}"`);
console.log(normalLines[j]);
}
var res = parseAttributes(normalLines[j])['RESOLUTION'];
if (res) {
lowResBestUrl += ' ';// The stream doesn't load unless each url line is unique
normalLines[j + 1] = lowResBestUrl;
}
}
}
encodingsM3u8 = normalLines.join('\r\n');
}
encodingsM3u8 = normalLines.join('\n');
}
streamInfo.BackupEncodings = encodingsM3u8;
setStreamInfoUrls(streamInfo, encodingsM3u8);
@@ -346,6 +333,10 @@ twitch-videoad.js text/javascript
//postMessage({key:'UboHideAdBanner'});
return textStr;
}
var currentResolution = streamInfo.Urls.get(url);
if (!currentResolution) {
return textStr;
}
var haveAdTags = textStr.includes(AD_SIGNIFIER);
if (SimulatedAdsDepth > 0 && (!streamInfo.BackupEncodings || !streamInfo.BackupEncodings.includes(url) || SimulatedAdsDepth - 1 > streamInfo.BackupEncodingsPlayerTypeIndex)) {
haveAdTags = true;
@@ -382,10 +373,10 @@ twitch-videoad.js text/javascript
}
}
if (streamInfo.BackupEncodings && haveAdTags) {
textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url);
textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url, currentResolution);
}
} else if (haveAdTags && !streamInfo.IsMovingOffBackupEncodings) {
textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url);
textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url, currentResolution);
} else {
postMessage({key:'UboHideAdBanner'});
}
@@ -424,14 +415,7 @@ twitch-videoad.js text/javascript
}
else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) {
var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
if (CurrentChannelNameFromM3U8 != channelName) {
postMessage({
key: 'UboChannelNameM3U8Changed',
value: channelName
});
}
CurrentChannelNameFromM3U8 = channelName;
if (OPT_STRIP_PARENT_DOMAINS) {
if (OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE) {
// parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads
var tempUrl = new URL(url);
tempUrl.searchParams.delete('parent_domains');
@@ -457,7 +441,8 @@ twitch-videoad.js text/javascript
IsMidroll: false,
UseFallbackStream: false,
ChannelName: channelName,
UsherParams: (new URL(url)).search
UsherParams: (new URL(url)).search,
Urls: new Map(),
};
var encodingsM3u8Response = await realFetch(url, options);
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
@@ -465,12 +450,12 @@ twitch-videoad.js text/javascript
streamInfo.Encodings = encodingsM3u8;
setStreamInfoUrls(streamInfo, encodingsM3u8);
serverTime = getServerTimeFromM3u8(encodingsM3u8);
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
var streamM3u8Response = await realFetch(streamM3u8Url);
var resolutionInfo = streamInfo.Urls.values().next().value;
var streamM3u8Response = await realFetch(resolutionInfo.Url);
if (streamM3u8Response.status == 200) {
var streamM3u8 = await streamM3u8Response.text();
if (streamM3u8.includes(AD_SIGNIFIER)) {
await onFoundAd(streamInfo, streamM3u8, false, realFetch, streamM3u8Url);
if (streamM3u8.includes(AD_SIGNIFIER) || SimulatedAdsDepth > 0) {
await onFoundAd(streamInfo, streamM3u8, false, realFetch, resolutionInfo.Url, resolutionInfo);
}
} else {
resolve(streamM3u8Response);
@@ -502,23 +487,36 @@ twitch-videoad.js text/javascript
function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) {
return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8;
}
function makeGraphQlPacket(event, radToken, payload) {
return [{
operationName: 'ClientSideAdEventHandling_RecordAdEvent',
variables: {
input: {
eventName: event,
eventPayload: JSON.stringify(payload),
radToken,
},
},
extensions: {
persistedQuery: {
version: 1,
sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b',
},
},
}];
function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) {
var encodingsLines = encodingsM3u8.replace('\r', '').split('\n');
const [targetWidth, targetHeight] = resolutionInfo.Resolution.split('x').map(Number);
var matchedResolutionUrl = null;
var matchedFrameRate = false;
var closestResolutionUrl = null;
var closestResolutionDifference = Infinity;
for (var i = 0; i < encodingsLines.length - 1; i++) {
if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) {
var attributes = parseAttributes(encodingsLines[i]);
var resolution = attributes['RESOLUTION'];
var frameRate = attributes['FRAME-RATE'];
if (resolution) {
if (resolution == resolutionInfo.Resolution && (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) {
matchedResolutionUrl = encodingsLines[i + 1];
matchedFrameRate = frameRate == resolutionInfo.FrameRate;
if (matchedFrameRate) {
return matchedResolutionUrl.trimEnd();
}
}
const [width, height] = resolution.split('x').map(Number);
var difference = Math.abs((width * height) - (targetWidth * targetHeight));
if (difference < closestResolutionDifference) {
closestResolutionUrl = encodingsLines[i + 1];
closestResolutionDifference = difference;
}
}
}
}
return closestResolutionUrl.trimEnd();
}
function getAccessToken(channelName, playerType, platform) {
if (!platform) {
@@ -548,9 +546,10 @@ twitch-videoad.js text/javascript
}
var headers = {
'Client-Id': CLIENT_ID,
'Client-Integrity': ClientIntegrityHeader,
'Device-ID': gql_device_id,
'X-Device-Id': gql_device_id,
'Authorization': AuthorizationHeader
'Authorization': AuthorizationHeader,
...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader})
};
return new Promise((resolve, reject) => {
const requestId = Math.random().toString(36).substring(2, 15);
@@ -590,73 +589,18 @@ twitch-videoad.js text/javascript
worker.postMessage({key: key, value: value});
});
}
function makeGmXmlHttpRequest(fetchRequest) {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: fetchRequest.options.method,
url: fetchRequest.url,
data: fetchRequest.options.body,
headers: fetchRequest.options.headers,
onload: response => resolve(response),
onerror: error => reject(error)
});
});
}
// Taken from https://github.com/dimdenGD/YeahTwitter/blob/9e0520f5abe029f57929795d8de0d2e5d3751cf3/us.js#L48
function parseHeaders(headersString) {
const headers = new Headers();
const lines = headersString.trim().split(/[\r\n]+/);
lines.forEach(line => {
const parts = line.split(':');
const header = parts.shift();
const value = parts.join(':');
headers.append(header, value);
});
return headers;
}
var serverLikesThisBrowser = false;
var serverHatesThisBrowser = false;
async function handleWorkerFetchRequest(fetchRequest) {
try {
if (serverLikesThisBrowser || !serverHatesThisBrowser) {
const response = await unsafeWindow.realFetch(fetchRequest.url, fetchRequest.options);
const responseBody = await response.text();
const responseObject = {
id: fetchRequest.id,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseBody
};
if (responseObject.status === 200) {
var resp = JSON.parse(responseBody);
if (typeof resp.errors !== 'undefined') {
serverHatesThisBrowser = true;
} else {
serverLikesThisBrowser = true;
}
}
if (serverLikesThisBrowser || !serverHatesThisBrowser) {
return responseObject;
}
}
if (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest !== 'undefined') {
fetchRequest.options.headers['Sec-Ch-Ua'] = '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"';
fetchRequest.options.headers['Referer'] = 'https://www.twitch.tv/';
fetchRequest.options.headers['Origin'] = 'https://www.twitch.tv/';
fetchRequest.options.headers['Host'] = 'gql.twitch.tv';
const response = await makeGmXmlHttpRequest(fetchRequest);
const responseBody = response.responseText;
const responseObject = {
id: fetchRequest.id,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(parseHeaders(response.responseHeaders).entries()),
body: responseBody
};
return responseObject;
}
throw { message: 'Failed to resolve GQL request. Try the userscript version of the ad blocking solution' };
const response = await window.realFetch(fetchRequest.url, fetchRequest.options);
const responseBody = await response.text();
const responseObject = {
id: fetchRequest.id,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseBody
};
return responseObject;
} catch (error) {
return {
id: fetchRequest.id,
@@ -665,48 +609,46 @@ twitch-videoad.js text/javascript
}
}
function hookFetch() {
var realFetch = unsafeWindow.fetch;
unsafeWindow.realFetch = realFetch;
unsafeWindow.fetch = function(url, init, ...args) {
var realFetch = window.fetch;
window.realFetch = realFetch;
window.fetch = function(url, init, ...args) {
if (typeof url === 'string') {
if (url.includes('gql')) {
var deviceId = init.headers['X-Device-Id'];
if (typeof deviceId !== 'string') {
deviceId = init.headers['Device-ID'];
}
if (typeof deviceId === 'string') {
if (typeof deviceId === 'string' && gql_device_id != deviceId) {
gql_device_id = deviceId;
}
if (gql_device_id) {
postTwitchWorkerMessage('UboUpdateDeviceId', gql_device_id);
}
if (OPT_ACCESS_TOKEN_PLAYER_TYPE && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture')) {
let replacedPlayerType = '';
const newBody = JSON.parse(init.body);
if (Array.isArray(newBody)) {
for (let i = 0; i < newBody.length; i++) {
if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType !== OPT_ACCESS_TOKEN_PLAYER_TYPE) {
replacedPlayerType = newBody[i].variables.playerType;
newBody[i].variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
}
}
} else {
if (newBody?.variables?.playerType && newBody?.variables?.playerType !== OPT_ACCESS_TOKEN_PLAYER_TYPE) {
replacedPlayerType = newBody.variables.playerType;
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
}
}
if (replacedPlayerType) {
console.log(`Replaced '${replacedPlayerType}' player type with '${OPT_ACCESS_TOKEN_PLAYER_TYPE}' player type`);
init.body = JSON.stringify(newBody);
}
}
if (typeof init.headers['Client-Integrity'] === 'string' && init.headers['Client-Integrity'] !== ClientIntegrityHeader) {
postTwitchWorkerMessage('UpdateClientIntegrityHeader', ClientIntegrityHeader = init.headers['Client-Integrity']);
}
if (typeof init.headers['Authorization'] === 'string' && init.headers['Authorization'] !== AuthorizationHeader) {
postTwitchWorkerMessage('UpdateAuthorizationHeader', AuthorizationHeader = init.headers['Authorization']);
}
if (OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture')) {
let replacedPlayerType = '';
const newBody = JSON.parse(init.body);
if (Array.isArray(newBody)) {
for (let i = 0; i < newBody.length; i++) {
if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType !== OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE) {
replacedPlayerType = newBody[i].variables.playerType;
newBody[i].variables.playerType = OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE;
}
}
} else {
if (newBody?.variables?.playerType && newBody?.variables?.playerType !== OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE) {
replacedPlayerType = newBody.variables.playerType;
newBody.variables.playerType = OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE;
}
}
if (replacedPlayerType) {
console.log(`Replaced '${replacedPlayerType}' player type with '${OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE}' player type`);
init.body = JSON.stringify(newBody);
}
}
}
}
return realFetch.apply(this, arguments);
@@ -781,20 +723,22 @@ twitch-videoad.js text/javascript
var currentQualityLS = localStorage.getItem(lsKeyQuality);
var currentMutedLS = localStorage.getItem(lsKeyMuted);
var currentVolumeLS = localStorage.getItem(lsKeyVolume);
if (player?.core?.state) {
if (localStorageHookFailed && player?.core?.state) {
localStorage.setItem(lsKeyMuted, JSON.stringify({default:player.core.state.muted}));
localStorage.setItem(lsKeyVolume, player.core.state.volume);
}
if (player?.core?.state?.quality?.group) {
if (localStorageHookFailed && player?.core?.state?.quality?.group) {
localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group}));
}
playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });
player.play();
setTimeout(() => {
localStorage.setItem(lsKeyQuality, currentQualityLS);
localStorage.setItem(lsKeyMuted, currentMutedLS);
localStorage.setItem(lsKeyVolume, currentVolumeLS);
}, 3000);
if (localStorageHookFailed) {
setTimeout(() => {
localStorage.setItem(lsKeyQuality, currentQualityLS);
localStorage.setItem(lsKeyMuted, currentMutedLS);
localStorage.setItem(lsKeyVolume, currentVolumeLS);
}, 3000);
}
}
function onContentLoaded() {
// This stops Twitch from pausing the player when in another tab and an ad shows.
@@ -827,7 +771,7 @@ twitch-videoad.js text/javascript
if (videos.length > 0) {
if (hidden.apply(document) === true || (webkitHidden && webkitHidden.apply(document) === true)) {
wasVideoPlaying = !videos[0].paused && !videos[0].ended;
} else if (wasVideoPlaying && !videos[0].ended) {
} else if (wasVideoPlaying && !videos[0].ended && videos[0].paused && videos[0].muted) {
videos[0].play();
}
}
@@ -879,19 +823,24 @@ twitch-videoad.js text/javascript
}
return realGetItem.apply(this, arguments);
};
if (!localStorage.getItem.toString().includes(Object.keys({cachedValues})[0])) {
// These hooks are useful to preserve player state on player reload
// Firefox doesn't allow hooking of localStorage functions but chrome does
localStorageHookFailed = true;
}
}
unsafeWindow.reloadTwitchPlayer = reloadTwitchPlayer;
declareOptions(unsafeWindow);
window.reloadTwitchPlayer = reloadTwitchPlayer;
declareOptions(window);
hookWindowWorker();
hookFetch();
if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") {
onContentLoaded();
} else {
unsafeWindow.addEventListener("DOMContentLoaded", function() {
window.addEventListener("DOMContentLoaded", function() {
onContentLoaded();
});
}
unsafeWindow.simulateAds = (depth) => {
window.simulateAds = (depth) => {
if (depth === undefined || depth < 0) {
console.log('Ad depth paramter required (0 = no simulated ad, 1+ = use backup player for given depth)');
return;
+170 -222
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name TwitchAdSolutions (video-swap-new)
// @namespace https://github.com/pixeltris/TwitchAdSolutions
// @version 1.45
// @version 1.46
// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap-new/video-swap-new.user.js
// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap-new/video-swap-new.user.js
// @description Multiple solutions for blocking Twitch ads (video-swap-new)
@@ -9,42 +9,36 @@
// @match *://*.twitch.tv/*
// @run-at document-start
// @inject-into page
// @grant GM.xmlHttpRequest
// @connect gql.twitch.tv
// @grant none
// ==/UserScript==
(function() {
'use strict';
var ourTwitchAdSolutionsVersion = 13;// Used to prevent conflicts with outdated versions of the scripts
if (typeof unsafeWindow === 'undefined') {
unsafeWindow = window;
}
if (typeof unsafeWindow.twitchAdSolutionsVersion !== 'undefined' && unsafeWindow.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) {
console.log("skipping video-swap-new as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + unsafeWindow.twitchAdSolutionsVersion);
unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
var ourTwitchAdSolutionsVersion = 14;// Used to prevent conflicts with outdated versions of the scripts
if (typeof window.twitchAdSolutionsVersion !== 'undefined' && window.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) {
console.log("skipping video-swap-new as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + window.twitchAdSolutionsVersion);
window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
return;
}
unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
function declareOptions(scope) {
// Options / globals
scope.OPT_PREROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ];
scope.OPT_MIDROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ];
scope.OPT_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ];
scope.OPT_BACKUP_PLATFORM = 'android';
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site';
scope.OPT_STRIP_PARENT_DOMAINS = true;
scope.OPT_SHOW_AD_BANNER = true;
scope.OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE = 'site';
scope.AD_SIGNIFIER = 'stitched-ad';
scope.LIVE_SIGNIFIER = ',live';
scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
// These are only really for Worker scope...
scope.StreamInfos = [];
scope.StreamInfosByUrl = [];
scope.CurrentChannelNameFromM3U8 = null;
// Need this in both scopes. Window scope needs to update this to worker scope.
scope.gql_device_id = null;
scope.ClientIntegrityHeader = null;
scope.AuthorizationHeader = null;
scope.AuthorizationHeader = undefined;
scope.SimulatedAdsDepth = 0;
}
var localStorageHookFailed = false;
var adBlockDiv = null;
var twitchWorkers = [];
var workerStringConflicts = [
'twitch',
@@ -104,8 +98,8 @@
|| workerStringReinsert.some((x) => workerString.includes(x));
}
function hookWindowWorker() {
var reinsert = getWorkersForReinsert(unsafeWindow.Worker);
var newWorker = class Worker extends getCleanWorker(unsafeWindow.Worker) {
var reinsert = getWorkersForReinsert(window.Worker);
var newWorker = class Worker extends getCleanWorker(window.Worker) {
constructor(twitchBlobUrl, options) {
var isTwitchWorker = false;
try {
@@ -122,18 +116,18 @@
${declareOptions.toString()}
${getAccessToken.toString()}
${gqlRequest.toString()}
${makeGraphQlPacket.toString()}
${parseAttributes.toString()}
${setStreamInfoUrls.toString()}
${onFoundAd.toString()}
${getWasmWorkerJs.toString()}
${getServerTimeFromM3u8.toString()}
${replaceServerTimeInM3u8.toString()}
${getStreamUrlForResolution.toString()}
var workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}');
declareOptions(self);
gql_device_id = ${gql_device_id ? "'" + gql_device_id + "'" : null};
AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : null};
ClientIntegrityHeader = ${AuthorizationHeader ? "'" + ClientIntegrityHeader + "'" : null};
AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined};
ClientIntegrityHeader = ${ClientIntegrityHeader ? "'" + ClientIntegrityHeader + "'" : null};
self.addEventListener('message', function(e) {
if (e.data.key == 'UboUpdateDeviceId') {
gql_device_id = e.data.value;
@@ -171,25 +165,23 @@
this.addEventListener('message', (e) => {
// NOTE: Removed adDiv caching as '.video-player' can change between streams?
if (e.data.key == 'UboShowAdBanner') {
var adDiv = getAdDiv();
if (adDiv != null) {
adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads';
if (OPT_SHOW_AD_BANNER) {
adDiv.style.display = 'block';
}
if (adBlockDiv == null) {
adBlockDiv = getAdDiv();
}
if (adBlockDiv != null) {
adBlockDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads';
adBlockDiv.style.display = 'block';
}
} else if (e.data.key == 'UboHideAdBanner') {
var adDiv = getAdDiv();
if (adDiv != null) {
adDiv.style.display = 'none';
if (adBlockDiv == null) {
adBlockDiv = getAdDiv();
}
if (adBlockDiv != null) {
adBlockDiv.style.display = 'none';
}
} else if (e.data.key == 'UboChannelNameM3U8Changed') {
//console.log('M3U8 channel name changed to ' + e.data.value);
} else if (e.data.key == 'UboReloadPlayer') {
reloadTwitchPlayer();
} else if (e.data.key == 'UboPauseResumePlayer') {
reloadTwitchPlayer(false, true);
} else if (e.data.key == 'UboSeekPlayer') {
reloadTwitchPlayer(true);
}
});
@@ -222,7 +214,7 @@
}
}
var workerInstance = reinsertWorkers(newWorker, reinsert);
Object.defineProperty(unsafeWindow, 'Worker', {
Object.defineProperty(window, 'Worker', {
get: function() {
return workerInstance;
},
@@ -244,22 +236,33 @@
}
function setStreamInfoUrls(streamInfo, encodingsM3u8) {
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var j = 0; j < lines.length; j++) {
if (!lines[j].startsWith('#') && lines[j].includes('.m3u8')) {
StreamInfosByUrl[lines[j].trimEnd()] = streamInfo;
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
StreamInfosByUrl[lines[i].trimEnd()] = streamInfo;
}
if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) {
var attributes = parseAttributes(lines[i]);
var resolution = attributes['RESOLUTION'];
if (resolution) {
var resolutionInfo = {
Resolution: resolution,
FrameRate: attributes['FRAME-RATE'],
Url: lines[i + 1]
};
streamInfo.Urls.set(lines[i + 1].trimEnd(), resolutionInfo);
}
}
}
}
async function onFoundAd(streamInfo, textStr, reloadPlayer, realFetch, url) {
async function onFoundAd(streamInfo, textStr, reloadPlayer, realFetch, url, resolutionInfo) {
var result = textStr;
streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"');
var playerTypes = streamInfo.IsMidroll ? OPT_MIDROLL_BACKUP_PLAYER_TYPES : OPT_PREROLL_BACKUP_PLAYER_TYPES;
var playerTypes = OPT_BACKUP_PLAYER_TYPES;
if (streamInfo.BackupEncodingsStatus.size >= playerTypes.length) {
return textStr;
}
if (streamInfo.BackupEncodings && !streamInfo.BackupEncodings.includes(url)) {
// NOTE: This might cause some freezing issues
var streamM3u8Url = streamInfo.BackupEncodings.match(/^https:.*\.m3u8.*$/m)[0];
var streamM3u8Url = getStreamUrlForResolution(streamInfo.BackupEncodings, resolutionInfo);
var streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status === 200) {
return await streamM3u8Response.text();
@@ -279,7 +282,7 @@
var encodingsM3u8Response = await realFetch(urlInfo.href);
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
var encodingsM3u8 = await encodingsM3u8Response.text();
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8.*$/m)[0];
var streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, resolutionInfo);
var streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status === 200) {
var backTextStr = await streamM3u8Response.text();
@@ -288,48 +291,31 @@
backupPlayerTypeInfo = ' (' + playerType + ')';
streamInfo.BackupEncodingsStatus.set(playerType, 1);
streamInfo.BackupEncodingsPlayerTypeIndex = i;
if (playerType !== 'embed') {
// Low resolution streams will reduce the number of resolutions in the UI. To fix this we merge the highest low res into the main m3u8
// TODO: Do a better matching up of the resolutions rather than picking the highest low res for all
var lowResLines = encodingsM3u8.replace('\r', '').split('\n');
var lowResBestUrl = null;
var lowResSettings = [];
for (var j = 0; j < lowResLines.length; j++) {
if (lowResLines[j].startsWith('#EXT-X-STREAM-INF')) {
var res = parseAttributes(lowResLines[j])['RESOLUTION'];
if (res && lowResLines[j + 1].endsWith('.m3u8')) {
// Assumes resolutions are correctly ordered
lowResBestUrl = lowResLines[j + 1];
lowResSettings = parseAttributes(lowResLines[j].substring(lowResLines[j].indexOf(':') + 1));
break;
if (playerType !== 'embed' && streamInfo.Encodings != null) {
// Low resolution streams will reduce the number of resolutions in the UI. To fix this we merge the low res URLs into the main m3u8
var normalEncodingsM3u8 = streamInfo.Encodings;
var normalLines = normalEncodingsM3u8.replace('\r', '').split('\n');
for (var j = 0; j < normalLines.length - 1; j++) {
if (normalLines[j].startsWith('#EXT-X-STREAM-INF')) {
var resSettings = parseAttributes(normalLines[j].substring(normalLines[j].indexOf(':') + 1));
var lowResUrl = getStreamUrlForResolution(encodingsM3u8, streamInfo.Urls.get(normalLines[j + 1].trimEnd()));
var lowResInf = encodingsM3u8.match(new RegExp(`^.*(?=\n.*${lowResUrl})`, 'm'))[0];
var lowResSettings = parseAttributes(lowResInf.substring(lowResInf.indexOf(':') + 1));
//console.log('map ' + resSettings['RESOLUTION'] + ' to ' + lowResSettings['RESOLUTION']);
const codecsKey = 'CODECS';
if (typeof resSettings[codecsKey] === 'string' && typeof lowResSettings[codecsKey] === 'string' &&
resSettings[codecsKey].length >= 3 && lowResSettings[codecsKey].length >= 3 &&
(resSettings[codecsKey].startsWith('hev') || resSettings[codecsKey].startsWith('hvc')) &&
resSettings[codecsKey].substring(0, 3) !== lowResSettings[codecsKey].substring(0, 3)
) {
console.log('swap ' + resSettings[codecsKey] + ' to ' + lowResSettings[codecsKey]);
normalLines[j] = normalLines[j].replace(/CODECS="[^"]+"/, `CODECS="${lowResSettings[codecsKey]}"`);
console.log(normalLines[j]);
}
normalLines[j + 1] = lowResUrl + ' '.repeat(j + 1);// The stream doesn't load unless each url line is unique
}
}
if (lowResBestUrl != null && streamInfo.Encodings != null) {
var normalEncodingsM3u8 = streamInfo.Encodings;
var normalLines = normalEncodingsM3u8.replace('\r', '').split('\n');
for (var j = 0; j < normalLines.length - 1; j++) {
if (normalLines[j].startsWith('#EXT-X-STREAM-INF')) {
var resSettings = parseAttributes(normalLines[j].substring(normalLines[j].indexOf(':') + 1));
const codecsKey = 'CODECS';
if (typeof resSettings[codecsKey] === 'string' && typeof lowResSettings[codecsKey] === 'string' &&
resSettings[codecsKey].length >= 3 && lowResSettings[codecsKey].length >= 3 &&
resSettings[codecsKey].substring(0, 3) === 'hev' &&
resSettings[codecsKey].substring(0, 3) !== lowResSettings[codecsKey].substring(0, 3)
) {
console.log('swap ' + resSettings[codecsKey] + ' to ' + lowResSettings[codecsKey]);
normalLines[j] = normalLines[j].replace(/CODECS="[^"]+"/, `CODECS="${lowResSettings[codecsKey]}"`);
console.log(normalLines[j]);
}
var res = parseAttributes(normalLines[j])['RESOLUTION'];
if (res) {
lowResBestUrl += ' ';// The stream doesn't load unless each url line is unique
normalLines[j + 1] = lowResBestUrl;
}
}
}
encodingsM3u8 = normalLines.join('\r\n');
}
encodingsM3u8 = normalLines.join('\n');
}
streamInfo.BackupEncodings = encodingsM3u8;
setStreamInfoUrls(streamInfo, encodingsM3u8);
@@ -359,6 +345,10 @@
//postMessage({key:'UboHideAdBanner'});
return textStr;
}
var currentResolution = streamInfo.Urls.get(url);
if (!currentResolution) {
return textStr;
}
var haveAdTags = textStr.includes(AD_SIGNIFIER);
if (SimulatedAdsDepth > 0 && (!streamInfo.BackupEncodings || !streamInfo.BackupEncodings.includes(url) || SimulatedAdsDepth - 1 > streamInfo.BackupEncodingsPlayerTypeIndex)) {
haveAdTags = true;
@@ -395,10 +385,10 @@
}
}
if (streamInfo.BackupEncodings && haveAdTags) {
textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url);
textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url, currentResolution);
}
} else if (haveAdTags && !streamInfo.IsMovingOffBackupEncodings) {
textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url);
textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url, currentResolution);
} else {
postMessage({key:'UboHideAdBanner'});
}
@@ -437,14 +427,7 @@
}
else if (url.includes('/api/channel/hls/') && !url.includes('picture-by-picture')) {
var channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0];
if (CurrentChannelNameFromM3U8 != channelName) {
postMessage({
key: 'UboChannelNameM3U8Changed',
value: channelName
});
}
CurrentChannelNameFromM3U8 = channelName;
if (OPT_STRIP_PARENT_DOMAINS) {
if (OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE) {
// parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads
var tempUrl = new URL(url);
tempUrl.searchParams.delete('parent_domains');
@@ -470,7 +453,8 @@
IsMidroll: false,
UseFallbackStream: false,
ChannelName: channelName,
UsherParams: (new URL(url)).search
UsherParams: (new URL(url)).search,
Urls: new Map(),
};
var encodingsM3u8Response = await realFetch(url, options);
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
@@ -478,12 +462,12 @@
streamInfo.Encodings = encodingsM3u8;
setStreamInfoUrls(streamInfo, encodingsM3u8);
serverTime = getServerTimeFromM3u8(encodingsM3u8);
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
var streamM3u8Response = await realFetch(streamM3u8Url);
var resolutionInfo = streamInfo.Urls.values().next().value;
var streamM3u8Response = await realFetch(resolutionInfo.Url);
if (streamM3u8Response.status == 200) {
var streamM3u8 = await streamM3u8Response.text();
if (streamM3u8.includes(AD_SIGNIFIER)) {
await onFoundAd(streamInfo, streamM3u8, false, realFetch, streamM3u8Url);
if (streamM3u8.includes(AD_SIGNIFIER) || SimulatedAdsDepth > 0) {
await onFoundAd(streamInfo, streamM3u8, false, realFetch, resolutionInfo.Url, resolutionInfo);
}
} else {
resolve(streamM3u8Response);
@@ -515,23 +499,36 @@
function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) {
return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8;
}
function makeGraphQlPacket(event, radToken, payload) {
return [{
operationName: 'ClientSideAdEventHandling_RecordAdEvent',
variables: {
input: {
eventName: event,
eventPayload: JSON.stringify(payload),
radToken,
},
},
extensions: {
persistedQuery: {
version: 1,
sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b',
},
},
}];
function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) {
var encodingsLines = encodingsM3u8.replace('\r', '').split('\n');
const [targetWidth, targetHeight] = resolutionInfo.Resolution.split('x').map(Number);
var matchedResolutionUrl = null;
var matchedFrameRate = false;
var closestResolutionUrl = null;
var closestResolutionDifference = Infinity;
for (var i = 0; i < encodingsLines.length - 1; i++) {
if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) {
var attributes = parseAttributes(encodingsLines[i]);
var resolution = attributes['RESOLUTION'];
var frameRate = attributes['FRAME-RATE'];
if (resolution) {
if (resolution == resolutionInfo.Resolution && (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) {
matchedResolutionUrl = encodingsLines[i + 1];
matchedFrameRate = frameRate == resolutionInfo.FrameRate;
if (matchedFrameRate) {
return matchedResolutionUrl.trimEnd();
}
}
const [width, height] = resolution.split('x').map(Number);
var difference = Math.abs((width * height) - (targetWidth * targetHeight));
if (difference < closestResolutionDifference) {
closestResolutionUrl = encodingsLines[i + 1];
closestResolutionDifference = difference;
}
}
}
}
return closestResolutionUrl.trimEnd();
}
function getAccessToken(channelName, playerType, platform) {
if (!platform) {
@@ -561,9 +558,10 @@
}
var headers = {
'Client-Id': CLIENT_ID,
'Client-Integrity': ClientIntegrityHeader,
'Device-ID': gql_device_id,
'X-Device-Id': gql_device_id,
'Authorization': AuthorizationHeader
'Authorization': AuthorizationHeader,
...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader})
};
return new Promise((resolve, reject) => {
const requestId = Math.random().toString(36).substring(2, 15);
@@ -603,73 +601,18 @@
worker.postMessage({key: key, value: value});
});
}
function makeGmXmlHttpRequest(fetchRequest) {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: fetchRequest.options.method,
url: fetchRequest.url,
data: fetchRequest.options.body,
headers: fetchRequest.options.headers,
onload: response => resolve(response),
onerror: error => reject(error)
});
});
}
// Taken from https://github.com/dimdenGD/YeahTwitter/blob/9e0520f5abe029f57929795d8de0d2e5d3751cf3/us.js#L48
function parseHeaders(headersString) {
const headers = new Headers();
const lines = headersString.trim().split(/[\r\n]+/);
lines.forEach(line => {
const parts = line.split(':');
const header = parts.shift();
const value = parts.join(':');
headers.append(header, value);
});
return headers;
}
var serverLikesThisBrowser = false;
var serverHatesThisBrowser = false;
async function handleWorkerFetchRequest(fetchRequest) {
try {
if (serverLikesThisBrowser || !serverHatesThisBrowser) {
const response = await unsafeWindow.realFetch(fetchRequest.url, fetchRequest.options);
const responseBody = await response.text();
const responseObject = {
id: fetchRequest.id,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseBody
};
if (responseObject.status === 200) {
var resp = JSON.parse(responseBody);
if (typeof resp.errors !== 'undefined') {
serverHatesThisBrowser = true;
} else {
serverLikesThisBrowser = true;
}
}
if (serverLikesThisBrowser || !serverHatesThisBrowser) {
return responseObject;
}
}
if (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest !== 'undefined') {
fetchRequest.options.headers['Sec-Ch-Ua'] = '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"';
fetchRequest.options.headers['Referer'] = 'https://www.twitch.tv/';
fetchRequest.options.headers['Origin'] = 'https://www.twitch.tv/';
fetchRequest.options.headers['Host'] = 'gql.twitch.tv';
const response = await makeGmXmlHttpRequest(fetchRequest);
const responseBody = response.responseText;
const responseObject = {
id: fetchRequest.id,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(parseHeaders(response.responseHeaders).entries()),
body: responseBody
};
return responseObject;
}
throw { message: 'Failed to resolve GQL request. Try the userscript version of the ad blocking solution' };
const response = await window.realFetch(fetchRequest.url, fetchRequest.options);
const responseBody = await response.text();
const responseObject = {
id: fetchRequest.id,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseBody
};
return responseObject;
} catch (error) {
return {
id: fetchRequest.id,
@@ -678,48 +621,46 @@
}
}
function hookFetch() {
var realFetch = unsafeWindow.fetch;
unsafeWindow.realFetch = realFetch;
unsafeWindow.fetch = function(url, init, ...args) {
var realFetch = window.fetch;
window.realFetch = realFetch;
window.fetch = function(url, init, ...args) {
if (typeof url === 'string') {
if (url.includes('gql')) {
var deviceId = init.headers['X-Device-Id'];
if (typeof deviceId !== 'string') {
deviceId = init.headers['Device-ID'];
}
if (typeof deviceId === 'string') {
if (typeof deviceId === 'string' && gql_device_id != deviceId) {
gql_device_id = deviceId;
}
if (gql_device_id) {
postTwitchWorkerMessage('UboUpdateDeviceId', gql_device_id);
}
if (OPT_ACCESS_TOKEN_PLAYER_TYPE && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture')) {
let replacedPlayerType = '';
const newBody = JSON.parse(init.body);
if (Array.isArray(newBody)) {
for (let i = 0; i < newBody.length; i++) {
if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType !== OPT_ACCESS_TOKEN_PLAYER_TYPE) {
replacedPlayerType = newBody[i].variables.playerType;
newBody[i].variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
}
}
} else {
if (newBody?.variables?.playerType && newBody?.variables?.playerType !== OPT_ACCESS_TOKEN_PLAYER_TYPE) {
replacedPlayerType = newBody.variables.playerType;
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
}
}
if (replacedPlayerType) {
console.log(`Replaced '${replacedPlayerType}' player type with '${OPT_ACCESS_TOKEN_PLAYER_TYPE}' player type`);
init.body = JSON.stringify(newBody);
}
}
if (typeof init.headers['Client-Integrity'] === 'string' && init.headers['Client-Integrity'] !== ClientIntegrityHeader) {
postTwitchWorkerMessage('UpdateClientIntegrityHeader', ClientIntegrityHeader = init.headers['Client-Integrity']);
}
if (typeof init.headers['Authorization'] === 'string' && init.headers['Authorization'] !== AuthorizationHeader) {
postTwitchWorkerMessage('UpdateAuthorizationHeader', AuthorizationHeader = init.headers['Authorization']);
}
if (OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture')) {
let replacedPlayerType = '';
const newBody = JSON.parse(init.body);
if (Array.isArray(newBody)) {
for (let i = 0; i < newBody.length; i++) {
if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType !== OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE) {
replacedPlayerType = newBody[i].variables.playerType;
newBody[i].variables.playerType = OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE;
}
}
} else {
if (newBody?.variables?.playerType && newBody?.variables?.playerType !== OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE) {
replacedPlayerType = newBody.variables.playerType;
newBody.variables.playerType = OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE;
}
}
if (replacedPlayerType) {
console.log(`Replaced '${replacedPlayerType}' player type with '${OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE}' player type`);
init.body = JSON.stringify(newBody);
}
}
}
}
return realFetch.apply(this, arguments);
@@ -794,20 +735,22 @@
var currentQualityLS = localStorage.getItem(lsKeyQuality);
var currentMutedLS = localStorage.getItem(lsKeyMuted);
var currentVolumeLS = localStorage.getItem(lsKeyVolume);
if (player?.core?.state) {
if (localStorageHookFailed && player?.core?.state) {
localStorage.setItem(lsKeyMuted, JSON.stringify({default:player.core.state.muted}));
localStorage.setItem(lsKeyVolume, player.core.state.volume);
}
if (player?.core?.state?.quality?.group) {
if (localStorageHookFailed && player?.core?.state?.quality?.group) {
localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group}));
}
playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });
player.play();
setTimeout(() => {
localStorage.setItem(lsKeyQuality, currentQualityLS);
localStorage.setItem(lsKeyMuted, currentMutedLS);
localStorage.setItem(lsKeyVolume, currentVolumeLS);
}, 3000);
if (localStorageHookFailed) {
setTimeout(() => {
localStorage.setItem(lsKeyQuality, currentQualityLS);
localStorage.setItem(lsKeyMuted, currentMutedLS);
localStorage.setItem(lsKeyVolume, currentVolumeLS);
}, 3000);
}
}
function onContentLoaded() {
// This stops Twitch from pausing the player when in another tab and an ad shows.
@@ -840,7 +783,7 @@
if (videos.length > 0) {
if (hidden.apply(document) === true || (webkitHidden && webkitHidden.apply(document) === true)) {
wasVideoPlaying = !videos[0].paused && !videos[0].ended;
} else if (wasVideoPlaying && !videos[0].ended) {
} else if (wasVideoPlaying && !videos[0].ended && videos[0].paused && videos[0].muted) {
videos[0].play();
}
}
@@ -892,19 +835,24 @@
}
return realGetItem.apply(this, arguments);
};
if (!localStorage.getItem.toString().includes(Object.keys({cachedValues})[0])) {
// These hooks are useful to preserve player state on player reload
// Firefox doesn't allow hooking of localStorage functions but chrome does
localStorageHookFailed = true;
}
}
unsafeWindow.reloadTwitchPlayer = reloadTwitchPlayer;
declareOptions(unsafeWindow);
window.reloadTwitchPlayer = reloadTwitchPlayer;
declareOptions(window);
hookWindowWorker();
hookFetch();
if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") {
onContentLoaded();
} else {
unsafeWindow.addEventListener("DOMContentLoaded", function() {
window.addEventListener("DOMContentLoaded", function() {
onContentLoaded();
});
}
unsafeWindow.simulateAds = (depth) => {
window.simulateAds = (depth) => {
if (depth === undefined || depth < 0) {
console.log('Ad depth paramter required (0 = no simulated ad, 1+ = use backup player for given depth)');
return;