From 464b11b135ae3e0e9cbc93fc831e7733ff61e17a Mon Sep 17 00:00:00 2001 From: pixeltris <6952411+pixeltris@users.noreply.github.com> Date: Fri, 21 Nov 2025 15:59:18 +0000 Subject: [PATCH] Fix vaft buffering mitigation triggering on vods - Fix bug where ad blocking banner would continue to show when navigating to vods / clips --- README.md | 2 +- full-list.md | 2 +- vaft/vaft-ublock-origin.js | 163 ++++++++++------- vaft/vaft.user.js | 165 +++++++++++------- .../video-swap-new-ublock-origin.js | 152 +++++++++------- video-swap-new/video-swap-new.user.js | 154 +++++++++------- 6 files changed, 376 insertions(+), 262 deletions(-) diff --git a/README.md b/README.md index 7ea2261..3be2876 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Alternatively: - 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/9cae451c04a9a94859da7de19c367b58afdd95bc/vaft/vaft-ublock-origin.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) + - If it fails to get a clean stream it removes ad segments (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/9cae451c04a9a94859da7de19c367b58afdd95bc/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) diff --git a/full-list.md b/full-list.md index f0b52fa..a8542d2 100644 --- a/full-list.md +++ b/full-list.md @@ -26,7 +26,7 @@ - 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). + - If it fails to get a clean stream it removes ad segments (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). diff --git a/vaft/vaft-ublock-origin.js b/vaft/vaft-ublock-origin.js index fd41579..595ba6c 100644 --- a/vaft/vaft-ublock-origin.js +++ b/vaft/vaft-ublock-origin.js @@ -2,7 +2,7 @@ twitch-videoad.js text/javascript (function() { if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } 'use strict'; - const ourTwitchAdSolutionsVersion = 17;// Used to prevent conflicts with outdated versions of the scripts + const ourTwitchAdSolutionsVersion = 18;// 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; @@ -19,7 +19,7 @@ twitch-videoad.js text/javascript 'picture-by-picture-CACHED'//360p (-CACHED is an internal suffix and is removed) ]; scope.FallbackPlayerType = 'embed'; - scope.ForceAccessTokenPlayerType = 'site'; + scope.ForceAccessTokenPlayerType = 'site';// Replaces 'embed' player type with 'site' (to reduce prerolls when on embeded websites) scope.SkipPlayerReloadOnHevc = false;// If true this will skip player reload on streams which have 2k/4k quality (if you enable this and you use the 2k/4k quality setting you'll get error #4000 / #3000 / spinning wheel on chrome based browsers) scope.AlwaysReloadPlayerOnAd = false;// Always pause/play when entering/leaving ads scope.ReloadPlayerAfterAd = true;// After the ad finishes do a player reload instead of pause/play @@ -49,7 +49,6 @@ twitch-videoad.js text/javascript let isActivelyStrippingAds = false; let localStorageHookFailed = false; const twitchWorkers = []; - let adBlockDiv = null; const workerStringConflicts = [ 'twitch', 'isVariantA'// TwitchNoSub @@ -186,20 +185,11 @@ twitch-videoad.js text/javascript twitchWorkers.push(this); this.addEventListener('message', (e) => { if (e.data.key == 'UpdateAdBlockBanner') { - if (adBlockDiv == null) { - adBlockDiv = getAdBlockDiv(); - } - if (adBlockDiv != null) { - 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'; - } + updateAdblockBanner(e.data); } else if (e.data.key == 'PauseResumePlayer') { - doTwitchPlayerTask(true, false, false); + doTwitchPlayerTask(true, false); } else if (e.data.key == 'ReloadPlayer') { - doTwitchPlayerTask(false, true, false); - } else if (e.data.key == 'MonitorPlayerForBuffering') { - doTwitchPlayerTask(false, false, true); + doTwitchPlayerTask(false, true); } }); this.addEventListener('message', async event => { @@ -212,23 +202,6 @@ twitch-videoad.js text/javascript }); } }); - 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 = '

'; - adBlockDiv.style.display = 'none'; - adBlockDiv.P = adBlockDiv.querySelector('p'); - playerRootDiv.appendChild(adBlockDiv); - } - } - return adBlockDiv; - } } }; let workerInstance = reinsertWorkers(newWorker, reinsert); @@ -303,13 +276,11 @@ twitch-videoad.js text/javascript 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, @@ -324,7 +295,8 @@ twitch-videoad.js text/javascript BackupEncodingsM3U8Cache: [], ActiveBackupPlayerType: null, IsMidroll: false, - IsStrippingAdSegments: false + IsStrippingAdSegments: false, + NumStrippedAdSegments: 0 }; const lines = encodingsM3u8.replaceAll('\r', '').split('\n'); for (let i = 0; i < lines.length - 1; i++) { @@ -372,12 +344,7 @@ twitch-videoad.js text/javascript } } } - postMessage({ - key: 'MonitorPlayerForBuffering' - }); - if (streamInfo.IsUsingModifiedM3U8 || isNewStream) { - streamInfo.LastPlayerReload = Date.now(); - } + streamInfo.LastPlayerReload = Date.now(); resolve(new Response(replaceServerTimeInM3u8(streamInfo.IsUsingModifiedM3U8 ? streamInfo.ModifiedM3U8 : streamInfo.EncodingsM3U8, serverTime))); } else { resolve(response); @@ -423,6 +390,9 @@ twitch-videoad.js text/javascript .replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`); if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) { const segmentUrl = lines[i + 1]; + if (!AdSegmentCache.has(segmentUrl)) { + streamInfo.NumStrippedAdSegments++; + } AdSegmentCache.set(segmentUrl, Date.now()); hasStrippedAdSegments = true; } @@ -437,6 +407,8 @@ twitch-videoad.js text/javascript lines[i] = ''; } } + } else { + streamInfo.NumStrippedAdSegments = 0; } streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; AdSegmentCache.forEach((key, value, map) => { @@ -489,6 +461,15 @@ twitch-videoad.js text/javascript const haveAdTags = textStr.includes(AdSignifier) || SimulatedAdsDepth > 0; if (haveAdTags) { streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); + if (!streamInfo.IsShowingAd) { + streamInfo.IsShowingAd = true; + postMessage({ + key: 'UpdateAdBlockBanner', + isMidroll: streamInfo.IsMidroll, + hasAds: streamInfo.IsShowingAd, + isStrippingAdSegments: false + }); + } if (!streamInfo.IsMidroll) { const lines = textStr.replaceAll('\r', '').split('\n'); for (let i = 0; i < lines.length; i++) { @@ -509,9 +490,6 @@ twitch-videoad.js text/javascript console.log('Ads will leak due to missing resolution info for ' + url); return textStr; } - if (!streamInfo.IsShowingAd) { - streamInfo.IsShowingAd = true; - } const isHevc = currentResolution.Codecs.startsWith('hev') || currentResolution.Codecs.startsWith('hvc'); if (((isHevc && !SkipPlayerReloadOnHevc) || AlwaysReloadPlayerOnAd) && streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) { streamInfo.IsUsingModifiedM3U8 = true; @@ -569,7 +547,12 @@ twitch-videoad.js text/javascript backupM3u8 = m3u8Text; break; } - if (isDoingMinimalRequests || isFullyCachedPlayerType) { + if (isFullyCachedPlayerType) { + break; + } + if (isDoingMinimalRequests) { + backupPlayerType = playerType; + backupM3u8 = m3u8Text; break; } } @@ -602,6 +585,7 @@ twitch-videoad.js text/javascript console.log('Finished blocking ads'); streamInfo.IsShowingAd = false; streamInfo.IsStrippingAdSegments = false; + streamInfo.NumStrippedAdSegments = 0; streamInfo.ActiveBackupPlayerType = null; if (streamInfo.IsUsingModifiedM3U8 || ReloadPlayerAfterAd) { streamInfo.IsUsingModifiedM3U8 = false; @@ -619,7 +603,8 @@ twitch-videoad.js text/javascript key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, - isStrippingAdSegments: streamInfo.IsStrippingAdSegments + isStrippingAdSegments: streamInfo.IsStrippingAdSegments, + numStrippedAdSegments: streamInfo.NumStrippedAdSegments }); return textStr; } @@ -698,19 +683,24 @@ twitch-videoad.js text/javascript bufferedPosition: 0, bufferDuration: 0, numSame: 0, - lastFixTime: 0 + lastFixTime: 0, + isLive: true }; function monitorPlayerBuffering() { if (playerForMonitoringBuffering) { try { - const player = playerForMonitoringBuffering; - if (!player.isPaused() && !player.getHTMLVideoElement()?.ended && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds) { + const player = playerForMonitoringBuffering.player; + const state = playerForMonitoringBuffering.state; + if (!player.core) { + playerForMonitoringBuffering = null; + } else if (state.props?.content?.type === 'live' && !player.isPaused() && !player.getHTMLVideoElement()?.ended && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds) { const position = player.core?.state?.position; const bufferedPosition = player.core?.state?.bufferedPosition; const bufferDuration = player.getBufferDuration(); //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) && + if (position > 0 && + (playerBufferState.position == position || bufferDuration < PlayerBufferingDangerZone) && playerBufferState.bufferedPosition == bufferedPosition && playerBufferState.bufferDuration >= bufferDuration && (position != 0 || bufferedPosition != 0 || bufferDuration != 0) @@ -719,6 +709,9 @@ twitch-videoad.js text/javascript if (playerBufferState.numSame == PlayerBufferingSameStateCount) { console.log('Attempt to fix buffering position:' + playerBufferState.position + ' bufferedPosition:' + playerBufferState.bufferedPosition + ' bufferDuration:' + playerBufferState.bufferDuration); doTwitchPlayerTask(!PlayerBufferingDoPlayerReload, PlayerBufferingDoPlayerReload, false); + const isPausePlay = !PlayerBufferingDoPlayerReload; + const isReload = PlayerBufferingDoPlayerReload; + doTwitchPlayerTask(isPausePlay, isReload); playerBufferState.lastFixTime = Date.now(); } } else { @@ -733,9 +726,45 @@ twitch-videoad.js text/javascript playerForMonitoringBuffering = null; } } + if (!playerForMonitoringBuffering) { + const playerAndState = getPlayerAndState(); + if (playerAndState && playerAndState.player && playerAndState.state) { + playerForMonitoringBuffering = { + player: playerAndState.player, + state: playerAndState.state + }; + } + } + const isLive = playerForMonitoringBuffering?.state?.props?.content?.type === 'live'; + if (playerBufferState.isLive && !isLive) { + updateAdblockBanner({ + hasAds: false + }); + } + playerBufferState.isLive = isLive; setTimeout(monitorPlayerBuffering, PlayerBufferingDelay); } - function doTwitchPlayerTask(isPausePlay, isReload, monitorPlayerForBuffering) { + function updateAdblockBanner(data) { + const playerRootDiv = document.querySelector('.video-player'); + if (playerRootDiv != null) { + let adBlockDiv = null; + adBlockDiv = playerRootDiv.querySelector('.adblock-overlay'); + if (adBlockDiv == null) { + adBlockDiv = document.createElement('div'); + adBlockDiv.className = 'adblock-overlay'; + adBlockDiv.innerHTML = '

'; + adBlockDiv.style.display = 'none'; + adBlockDiv.P = adBlockDiv.querySelector('p'); + playerRootDiv.appendChild(adBlockDiv); + } + if (adBlockDiv != null) { + isActivelyStrippingAds = data.isStrippingAdSegments; + adBlockDiv.P.textContent = 'Blocking' + (data.isMidroll ? ' midroll' : '') + ' ads' + (data.isStrippingAdSegments ? ' (stripping)' : '');// + (data.numStrippedAdSegments > 0 ? ` (${data.numStrippedAdSegments})` : ''); + adBlockDiv.style.display = data.hasAds && playerBufferState.isLive ? 'block' : 'none'; + } + } + } + function getPlayerAndState() { function findReactNode(root, constraint) { if (root.stateNode && constraint(root.stateNode)) { return root.stateNode; @@ -756,7 +785,7 @@ twitch-videoad.js text/javascript if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { reactRootNode = rootNode._reactRootContainer._internalRoot.current; } - if (reactRootNode == null) { + if (reactRootNode == null && rootNode != null) { const containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer')); if (containerName != null) { reactRootNode = rootNode[containerName]; @@ -766,12 +795,24 @@ twitch-videoad.js text/javascript } const reactRootNode = findReactRootNode(); if (!reactRootNode) { - console.log('Could not find react root'); - return; + return null; } let player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; const playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); + return { + player: player, + state: playerState + }; + } + function doTwitchPlayerTask(isPausePlay, isReload) { + const playerAndState = getPlayerAndState(); + if (!playerAndState) { + console.log('Could not find react root'); + return; + } + const player = playerAndState.player; + const playerState = playerAndState.state; if (!player) { console.log('Could not find player'); return; @@ -780,16 +821,9 @@ twitch-videoad.js text/javascript console.log('Could not find player state'); return; } - if (!playerForMonitoringBuffering) { - playerForMonitoringBuffering = player; - } if (player.isPaused() || player.core?.paused) { return; } - if (monitorPlayerForBuffering) { - playerBufferState.lastFixTime = 0; - return; - } if (isPausePlay) { player.pause(); player.play(); @@ -889,18 +923,19 @@ twitch-videoad.js text/javascript if (typeof init.headers['Authorization'] === 'string' && init.headers['Authorization'] !== AuthorizationHeader) { postTwitchWorkerMessage('UpdateAuthorizationHeader', AuthorizationHeader = init.headers['Authorization']); } - if (ForceAccessTokenPlayerType && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture') && !init.body.includes('frontpage')) { + if (ForceAccessTokenPlayerType && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const targetPlayerType = 'embed'; 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) { + if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType === targetPlayerType) { replacedPlayerType = newBody[i].variables.playerType; newBody[i].variables.playerType = ForceAccessTokenPlayerType; } } } else { - if (newBody?.variables?.playerType && newBody?.variables?.playerType !== ForceAccessTokenPlayerType) { + if (newBody?.variables?.playerType && newBody?.variables?.playerType === targetPlayerType) { replacedPlayerType = newBody.variables.playerType; newBody.variables.playerType = ForceAccessTokenPlayerType; } diff --git a/vaft/vaft.user.js b/vaft/vaft.user.js index 0023813..0d641c0 100644 --- a/vaft/vaft.user.js +++ b/vaft/vaft.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name TwitchAdSolutions (vaft) // @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 30.0.0 +// @version 31.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'; - const ourTwitchAdSolutionsVersion = 17;// Used to prevent conflicts with outdated versions of the scripts + const ourTwitchAdSolutionsVersion = 18;// 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; @@ -30,7 +30,7 @@ 'picture-by-picture-CACHED'//360p (-CACHED is an internal suffix and is removed) ]; scope.FallbackPlayerType = 'embed'; - scope.ForceAccessTokenPlayerType = 'site'; + scope.ForceAccessTokenPlayerType = 'site';// Replaces 'embed' player type with 'site' (to reduce prerolls when on embeded websites) scope.SkipPlayerReloadOnHevc = false;// If true this will skip player reload on streams which have 2k/4k quality (if you enable this and you use the 2k/4k quality setting you'll get error #4000 / #3000 / spinning wheel on chrome based browsers) scope.AlwaysReloadPlayerOnAd = false;// Always pause/play when entering/leaving ads scope.ReloadPlayerAfterAd = true;// After the ad finishes do a player reload instead of pause/play @@ -60,7 +60,6 @@ let isActivelyStrippingAds = false; let localStorageHookFailed = false; const twitchWorkers = []; - let adBlockDiv = null; const workerStringConflicts = [ 'twitch', 'isVariantA'// TwitchNoSub @@ -197,20 +196,11 @@ twitchWorkers.push(this); this.addEventListener('message', (e) => { if (e.data.key == 'UpdateAdBlockBanner') { - if (adBlockDiv == null) { - adBlockDiv = getAdBlockDiv(); - } - if (adBlockDiv != null) { - 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'; - } + updateAdblockBanner(e.data); } else if (e.data.key == 'PauseResumePlayer') { - doTwitchPlayerTask(true, false, false); + doTwitchPlayerTask(true, false); } else if (e.data.key == 'ReloadPlayer') { - doTwitchPlayerTask(false, true, false); - } else if (e.data.key == 'MonitorPlayerForBuffering') { - doTwitchPlayerTask(false, false, true); + doTwitchPlayerTask(false, true); } }); this.addEventListener('message', async event => { @@ -223,23 +213,6 @@ }); } }); - 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 = '

'; - adBlockDiv.style.display = 'none'; - adBlockDiv.P = adBlockDiv.querySelector('p'); - playerRootDiv.appendChild(adBlockDiv); - } - } - return adBlockDiv; - } } }; let workerInstance = reinsertWorkers(newWorker, reinsert); @@ -314,13 +287,11 @@ 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, @@ -335,7 +306,8 @@ BackupEncodingsM3U8Cache: [], ActiveBackupPlayerType: null, IsMidroll: false, - IsStrippingAdSegments: false + IsStrippingAdSegments: false, + NumStrippedAdSegments: 0 }; const lines = encodingsM3u8.replaceAll('\r', '').split('\n'); for (let i = 0; i < lines.length - 1; i++) { @@ -383,12 +355,7 @@ } } } - postMessage({ - key: 'MonitorPlayerForBuffering' - }); - if (streamInfo.IsUsingModifiedM3U8 || isNewStream) { - streamInfo.LastPlayerReload = Date.now(); - } + streamInfo.LastPlayerReload = Date.now(); resolve(new Response(replaceServerTimeInM3u8(streamInfo.IsUsingModifiedM3U8 ? streamInfo.ModifiedM3U8 : streamInfo.EncodingsM3U8, serverTime))); } else { resolve(response); @@ -434,6 +401,9 @@ .replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`); if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) { const segmentUrl = lines[i + 1]; + if (!AdSegmentCache.has(segmentUrl)) { + streamInfo.NumStrippedAdSegments++; + } AdSegmentCache.set(segmentUrl, Date.now()); hasStrippedAdSegments = true; } @@ -448,6 +418,8 @@ lines[i] = ''; } } + } else { + streamInfo.NumStrippedAdSegments = 0; } streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; AdSegmentCache.forEach((key, value, map) => { @@ -500,6 +472,15 @@ const haveAdTags = textStr.includes(AdSignifier) || SimulatedAdsDepth > 0; if (haveAdTags) { streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); + if (!streamInfo.IsShowingAd) { + streamInfo.IsShowingAd = true; + postMessage({ + key: 'UpdateAdBlockBanner', + isMidroll: streamInfo.IsMidroll, + hasAds: streamInfo.IsShowingAd, + isStrippingAdSegments: false + }); + } if (!streamInfo.IsMidroll) { const lines = textStr.replaceAll('\r', '').split('\n'); for (let i = 0; i < lines.length; i++) { @@ -520,9 +501,6 @@ console.log('Ads will leak due to missing resolution info for ' + url); return textStr; } - if (!streamInfo.IsShowingAd) { - streamInfo.IsShowingAd = true; - } const isHevc = currentResolution.Codecs.startsWith('hev') || currentResolution.Codecs.startsWith('hvc'); if (((isHevc && !SkipPlayerReloadOnHevc) || AlwaysReloadPlayerOnAd) && streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) { streamInfo.IsUsingModifiedM3U8 = true; @@ -580,7 +558,12 @@ backupM3u8 = m3u8Text; break; } - if (isDoingMinimalRequests || isFullyCachedPlayerType) { + if (isFullyCachedPlayerType) { + break; + } + if (isDoingMinimalRequests) { + backupPlayerType = playerType; + backupM3u8 = m3u8Text; break; } } @@ -613,6 +596,7 @@ console.log('Finished blocking ads'); streamInfo.IsShowingAd = false; streamInfo.IsStrippingAdSegments = false; + streamInfo.NumStrippedAdSegments = 0; streamInfo.ActiveBackupPlayerType = null; if (streamInfo.IsUsingModifiedM3U8 || ReloadPlayerAfterAd) { streamInfo.IsUsingModifiedM3U8 = false; @@ -630,7 +614,8 @@ key: 'UpdateAdBlockBanner', isMidroll: streamInfo.IsMidroll, hasAds: streamInfo.IsShowingAd, - isStrippingAdSegments: streamInfo.IsStrippingAdSegments + isStrippingAdSegments: streamInfo.IsStrippingAdSegments, + numStrippedAdSegments: streamInfo.NumStrippedAdSegments }); return textStr; } @@ -709,19 +694,24 @@ bufferedPosition: 0, bufferDuration: 0, numSame: 0, - lastFixTime: 0 + lastFixTime: 0, + isLive: true }; function monitorPlayerBuffering() { if (playerForMonitoringBuffering) { try { - const player = playerForMonitoringBuffering; - if (!player.isPaused() && !player.getHTMLVideoElement()?.ended && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds) { + const player = playerForMonitoringBuffering.player; + const state = playerForMonitoringBuffering.state; + if (!player.core) { + playerForMonitoringBuffering = null; + } else if (state.props?.content?.type === 'live' && !player.isPaused() && !player.getHTMLVideoElement()?.ended && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds) { const position = player.core?.state?.position; const bufferedPosition = player.core?.state?.bufferedPosition; const bufferDuration = player.getBufferDuration(); //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) && + if (position > 0 && + (playerBufferState.position == position || bufferDuration < PlayerBufferingDangerZone) && playerBufferState.bufferedPosition == bufferedPosition && playerBufferState.bufferDuration >= bufferDuration && (position != 0 || bufferedPosition != 0 || bufferDuration != 0) @@ -730,6 +720,9 @@ if (playerBufferState.numSame == PlayerBufferingSameStateCount) { console.log('Attempt to fix buffering position:' + playerBufferState.position + ' bufferedPosition:' + playerBufferState.bufferedPosition + ' bufferDuration:' + playerBufferState.bufferDuration); doTwitchPlayerTask(!PlayerBufferingDoPlayerReload, PlayerBufferingDoPlayerReload, false); + const isPausePlay = !PlayerBufferingDoPlayerReload; + const isReload = PlayerBufferingDoPlayerReload; + doTwitchPlayerTask(isPausePlay, isReload); playerBufferState.lastFixTime = Date.now(); } } else { @@ -744,9 +737,45 @@ playerForMonitoringBuffering = null; } } + if (!playerForMonitoringBuffering) { + const playerAndState = getPlayerAndState(); + if (playerAndState && playerAndState.player && playerAndState.state) { + playerForMonitoringBuffering = { + player: playerAndState.player, + state: playerAndState.state + }; + } + } + const isLive = playerForMonitoringBuffering?.state?.props?.content?.type === 'live'; + if (playerBufferState.isLive && !isLive) { + updateAdblockBanner({ + hasAds: false + }); + } + playerBufferState.isLive = isLive; setTimeout(monitorPlayerBuffering, PlayerBufferingDelay); } - function doTwitchPlayerTask(isPausePlay, isReload, monitorPlayerForBuffering) { + function updateAdblockBanner(data) { + const playerRootDiv = document.querySelector('.video-player'); + if (playerRootDiv != null) { + let adBlockDiv = null; + adBlockDiv = playerRootDiv.querySelector('.adblock-overlay'); + if (adBlockDiv == null) { + adBlockDiv = document.createElement('div'); + adBlockDiv.className = 'adblock-overlay'; + adBlockDiv.innerHTML = '

'; + adBlockDiv.style.display = 'none'; + adBlockDiv.P = adBlockDiv.querySelector('p'); + playerRootDiv.appendChild(adBlockDiv); + } + if (adBlockDiv != null) { + isActivelyStrippingAds = data.isStrippingAdSegments; + adBlockDiv.P.textContent = 'Blocking' + (data.isMidroll ? ' midroll' : '') + ' ads' + (data.isStrippingAdSegments ? ' (stripping)' : '');// + (data.numStrippedAdSegments > 0 ? ` (${data.numStrippedAdSegments})` : ''); + adBlockDiv.style.display = data.hasAds && playerBufferState.isLive ? 'block' : 'none'; + } + } + } + function getPlayerAndState() { function findReactNode(root, constraint) { if (root.stateNode && constraint(root.stateNode)) { return root.stateNode; @@ -767,7 +796,7 @@ if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { reactRootNode = rootNode._reactRootContainer._internalRoot.current; } - if (reactRootNode == null) { + if (reactRootNode == null && rootNode != null) { const containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer')); if (containerName != null) { reactRootNode = rootNode[containerName]; @@ -777,12 +806,24 @@ } const reactRootNode = findReactRootNode(); if (!reactRootNode) { - console.log('Could not find react root'); - return; + return null; } let player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; const playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); + return { + player: player, + state: playerState + }; + } + function doTwitchPlayerTask(isPausePlay, isReload) { + const playerAndState = getPlayerAndState(); + if (!playerAndState) { + console.log('Could not find react root'); + return; + } + const player = playerAndState.player; + const playerState = playerAndState.state; if (!player) { console.log('Could not find player'); return; @@ -791,16 +832,9 @@ console.log('Could not find player state'); return; } - if (!playerForMonitoringBuffering) { - playerForMonitoringBuffering = player; - } if (player.isPaused() || player.core?.paused) { return; } - if (monitorPlayerForBuffering) { - playerBufferState.lastFixTime = 0; - return; - } if (isPausePlay) { player.pause(); player.play(); @@ -900,18 +934,19 @@ if (typeof init.headers['Authorization'] === 'string' && init.headers['Authorization'] !== AuthorizationHeader) { postTwitchWorkerMessage('UpdateAuthorizationHeader', AuthorizationHeader = init.headers['Authorization']); } - if (ForceAccessTokenPlayerType && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture') && !init.body.includes('frontpage')) { + if (ForceAccessTokenPlayerType && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const targetPlayerType = 'embed'; 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) { + if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType === targetPlayerType) { replacedPlayerType = newBody[i].variables.playerType; newBody[i].variables.playerType = ForceAccessTokenPlayerType; } } } else { - if (newBody?.variables?.playerType && newBody?.variables?.playerType !== ForceAccessTokenPlayerType) { + if (newBody?.variables?.playerType && newBody?.variables?.playerType === targetPlayerType) { replacedPlayerType = newBody.variables.playerType; newBody.variables.playerType = ForceAccessTokenPlayerType; } diff --git a/video-swap-new/video-swap-new-ublock-origin.js b/video-swap-new/video-swap-new-ublock-origin.js index fa9e50b..bde375d 100644 --- a/video-swap-new/video-swap-new-ublock-origin.js +++ b/video-swap-new/video-swap-new-ublock-origin.js @@ -1,7 +1,7 @@ twitch-videoad.js text/javascript (function() { if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } - const ourTwitchAdSolutionsVersion = 17;// Used to prevent conflicts with outdated versions of the scripts + const ourTwitchAdSolutionsVersion = 18;// 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; @@ -29,8 +29,8 @@ twitch-videoad.js text/javascript scope.AdSegmentCache = new Map(); scope.AllSegmentsAreAdSegments = false; } + let twitchPlayerAndState = null; let localStorageHookFailed = false; - let adBlockDiv = null; const twitchWorkers = []; const workerStringConflicts = [ 'twitch', @@ -116,6 +116,7 @@ twitch-videoad.js text/javascript ${getServerTimeFromM3u8.toString()} ${replaceServerTimeInM3u8.toString()} ${getStreamUrlForResolution.toString()} + ${updateAdblockBannerForStream.toString()} const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}'); declareOptions(self); gql_device_id = ${gql_device_id ? "'" + gql_device_id + "'" : null}; @@ -159,33 +160,8 @@ twitch-videoad.js text/javascript super(URL.createObjectURL(new Blob([newBlobStr])), options); twitchWorkers.push(this); this.addEventListener('message', (e) => { - // NOTE: Removed adDiv caching as '.video-player' can change between streams? - if (e.data.key == 'UboShowAdBanner' || e.data.key == 'UpdateAdBlockBannerStripping') { - if (adBlockDiv == null) { - adBlockDiv = getAdDiv(); - } - if (adBlockDiv != null) { - 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) { - adBlockDiv = getAdDiv(); - } - if (adBlockDiv != null) { - adBlockDiv.style.display = 'none'; - } + if (e.data.key == 'UboUpdateAdBanner') { + updateAdblockBanner(e.data); } else if (e.data.key == 'UboReloadPlayer') { reloadTwitchPlayer(false); } else if (e.data.key == 'UboPauseResumePlayer') { @@ -202,22 +178,6 @@ twitch-videoad.js text/javascript }); } }); - function getAdDiv() { - const playerRootDiv = document.querySelector('.video-player'); - let adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } } } let workerInstance = reinsertWorkers(newWorker, reinsert); @@ -261,6 +221,20 @@ twitch-videoad.js text/javascript } } } + function updateAdblockBannerForStream(streamInfo) { + const isShowingAd = !!streamInfo.BackupEncodings; + if (!isShowingAd && (streamInfo.IsStrippingAdSegments || streamInfo.NumStrippedAdSegments > 0)) { + streamInfo.IsStrippingAdSegments = false; + streamInfo.NumStrippedAdSegments = 0; + } + postMessage({ + key: 'UboUpdateAdBanner', + isMidroll: streamInfo.IsMidroll, + hasAds: isShowingAd, + isStrippingAdSegments: streamInfo.IsStrippingAdSegments, + numStrippedAdSegments: streamInfo.NumStrippedAdSegments + }); + } async function onFoundAd(streamInfo, textStr, reloadPlayer, realFetch, url, resolutionInfo) { let result = textStr; streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); @@ -342,10 +316,10 @@ twitch-videoad.js text/javascript if (reloadPlayer) { postMessage({key:'UboReloadPlayer'}); } - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); + updateAdblockBannerForStream(streamInfo); return result; } - function stripAdSegments(textStr, stripAllSegments) { + function stripAdSegments(textStr, stripAllSegments, streamInfo) { let hasStrippedAdSegments = false; const lines = textStr.replaceAll('\r', '').split('\n'); const newAdUrl = 'https://twitch.tv'; @@ -357,6 +331,9 @@ twitch-videoad.js text/javascript .replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`); if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) { const segmentUrl = lines[i + 1]; + if (!AdSegmentCache.has(segmentUrl)) { + streamInfo.NumStrippedAdSegments++; + } AdSegmentCache.set(segmentUrl, Date.now()); hasStrippedAdSegments = true; } @@ -371,16 +348,15 @@ twitch-videoad.js text/javascript lines[i] = ''; } } + } else { + streamInfo.NumStrippedAdSegments = 0; } + streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; 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) { @@ -405,7 +381,6 @@ twitch-videoad.js text/javascript streamInfo.BackupEncodings = null; streamInfo.BackupEncodingsStatus.clear(); streamInfo.BackupEncodingsPlayerTypeIndex = -1; - postMessage({key:'UboHideAdBanner'}); postMessage({key:'UboReloadPlayer'}); } else if (!streamM3u8.includes('"MIDROLL"') && !streamM3u8.includes('"midroll"')) { const lines = streamM3u8.replaceAll('\r', '').split('\n'); @@ -429,12 +404,11 @@ twitch-videoad.js text/javascript } } else if (haveAdTags && !streamInfo.IsMovingOffBackupEncodings) { textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url, currentResolution); - } else { - postMessage({key:'UboHideAdBanner'}); } if (IsAdStrippingEnabled) { - textStr = stripAdSegments(textStr); + textStr = stripAdSegments(textStr, false, streamInfo); } + updateAdblockBannerForStream(streamInfo); return textStr; } function hookWorkerFetch() { @@ -507,6 +481,8 @@ twitch-videoad.js text/javascript BackupEncodingsPlayerTypeIndex: -1, IsMovingOffBackupEncodings: false, IsMidroll: false, + IsStrippingAdSegments: false, + NumStrippedAdSegments: 0, UseFallbackStream: false, ChannelName: channelName, UsherParams: (new URL(url)).search, @@ -710,18 +686,19 @@ twitch-videoad.js text/javascript 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') && !init.body.includes('frontpage')) { + if (OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const targetPlayerType = 'embed'; 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) { + if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType === targetPlayerType) { 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) { + if (newBody?.variables?.playerType && newBody?.variables?.playerType === targetPlayerType) { replacedPlayerType = newBody.variables.playerType; newBody.variables.playerType = OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE; } @@ -752,10 +729,42 @@ twitch-videoad.js text/javascript 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 updateAdblockBanner(data) { + const playerRootDiv = document.querySelector('.video-player'); + if (playerRootDiv != null) { + let adBlockDiv = null; + adBlockDiv = playerRootDiv.querySelector('.adblock-overlay'); + if (adBlockDiv == null) { + adBlockDiv = document.createElement('div'); + adBlockDiv.className = 'adblock-overlay'; + adBlockDiv.innerHTML = '

'; + adBlockDiv.style.display = 'none'; + adBlockDiv.P = adBlockDiv.querySelector('p'); + playerRootDiv.appendChild(adBlockDiv); + } + if (adBlockDiv != null) { + if (!twitchPlayerAndState?.player?.core || !twitchPlayerAndState?.state) { + twitchPlayerAndState = getPlayerAndState(); + } + const isLive = twitchPlayerAndState?.state?.props?.content?.type === 'live'; + adBlockDiv.P.textContent = 'Blocking' + (data.isMidroll ? ' midroll' : '') + ' ads' + (data.isStrippingAdSegments ? ' (stripping)' : '');// + (data.numStrippedAdSegments > 0 ? ` (${data.numStrippedAdSegments})` : ''); + adBlockDiv.style.display = data.hasAds && isLive ? 'block' : 'none'; + } + } + } + function monitorLiveStatus() { + if (!twitchPlayerAndState?.player?.core || !twitchPlayerAndState?.state) { + twitchPlayerAndState = getPlayerAndState(); + } + const isLive = twitchPlayerAndState?.state?.props?.content?.type === 'live'; + if (!isLive) { + updateAdblockBanner({ + hasAds: false + }); + } + setTimeout(monitorLiveStatus, 1000); + } + function getPlayerAndState() { function findReactNode(root, constraint) { if (root.stateNode && constraint(root.stateNode)) { return root.stateNode; @@ -776,7 +785,7 @@ twitch-videoad.js text/javascript if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { reactRootNode = rootNode._reactRootContainer._internalRoot.current; } - if (reactRootNode == null) { + if (reactRootNode == null && rootNode != null) { const containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer')); if (containerName != null) { reactRootNode = rootNode[containerName]; @@ -786,12 +795,24 @@ twitch-videoad.js text/javascript } const reactRootNode = findReactRootNode(); if (!reactRootNode) { - console.log('Could not find react root'); - return; + return null; } let player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; const playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); + return { + player: player, + state: playerState + }; + } + function reloadTwitchPlayer(isPausePlay) { + const playerAndState = getPlayerAndState(); + if (!playerAndState) { + console.log('Could not find react root'); + return; + } + const player = playerAndState.player; + const playerState = playerAndState.state; if (!player) { console.log('Could not find player'); return; @@ -942,6 +963,7 @@ twitch-videoad.js text/javascript declareOptions(window); hookWindowWorker(); hookFetch(); + monitorLiveStatus(); if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { onContentLoaded(); } else { diff --git a/video-swap-new/video-swap-new.user.js b/video-swap-new/video-swap-new.user.js index 0f0c178..37af4d0 100644 --- a/video-swap-new/video-swap-new.user.js +++ b/video-swap-new/video-swap-new.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name TwitchAdSolutions (video-swap-new) // @namespace https://github.com/pixeltris/TwitchAdSolutions -// @version 1.49 +// @version 1.50 // @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'; - const ourTwitchAdSolutionsVersion = 17;// Used to prevent conflicts with outdated versions of the scripts + const ourTwitchAdSolutionsVersion = 18;// 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; @@ -41,8 +41,8 @@ scope.AdSegmentCache = new Map(); scope.AllSegmentsAreAdSegments = false; } + let twitchPlayerAndState = null; let localStorageHookFailed = false; - let adBlockDiv = null; const twitchWorkers = []; const workerStringConflicts = [ 'twitch', @@ -128,6 +128,7 @@ ${getServerTimeFromM3u8.toString()} ${replaceServerTimeInM3u8.toString()} ${getStreamUrlForResolution.toString()} + ${updateAdblockBannerForStream.toString()} const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}'); declareOptions(self); gql_device_id = ${gql_device_id ? "'" + gql_device_id + "'" : null}; @@ -171,33 +172,8 @@ super(URL.createObjectURL(new Blob([newBlobStr])), options); twitchWorkers.push(this); this.addEventListener('message', (e) => { - // NOTE: Removed adDiv caching as '.video-player' can change between streams? - if (e.data.key == 'UboShowAdBanner' || e.data.key == 'UpdateAdBlockBannerStripping') { - if (adBlockDiv == null) { - adBlockDiv = getAdDiv(); - } - if (adBlockDiv != null) { - 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) { - adBlockDiv = getAdDiv(); - } - if (adBlockDiv != null) { - adBlockDiv.style.display = 'none'; - } + if (e.data.key == 'UboUpdateAdBanner') { + updateAdblockBanner(e.data); } else if (e.data.key == 'UboReloadPlayer') { reloadTwitchPlayer(false); } else if (e.data.key == 'UboPauseResumePlayer') { @@ -214,22 +190,6 @@ }); } }); - function getAdDiv() { - const playerRootDiv = document.querySelector('.video-player'); - let adDiv = null; - if (playerRootDiv != null) { - adDiv = playerRootDiv.querySelector('.ubo-overlay'); - if (adDiv == null) { - adDiv = document.createElement('div'); - adDiv.className = 'ubo-overlay'; - adDiv.innerHTML = '

'; - adDiv.style.display = 'none'; - adDiv.P = adDiv.querySelector('p'); - playerRootDiv.appendChild(adDiv); - } - } - return adDiv; - } } } let workerInstance = reinsertWorkers(newWorker, reinsert); @@ -273,6 +233,20 @@ } } } + function updateAdblockBannerForStream(streamInfo) { + const isShowingAd = !!streamInfo.BackupEncodings; + if (!isShowingAd && (streamInfo.IsStrippingAdSegments || streamInfo.NumStrippedAdSegments > 0)) { + streamInfo.IsStrippingAdSegments = false; + streamInfo.NumStrippedAdSegments = 0; + } + postMessage({ + key: 'UboUpdateAdBanner', + isMidroll: streamInfo.IsMidroll, + hasAds: isShowingAd, + isStrippingAdSegments: streamInfo.IsStrippingAdSegments, + numStrippedAdSegments: streamInfo.NumStrippedAdSegments + }); + } async function onFoundAd(streamInfo, textStr, reloadPlayer, realFetch, url, resolutionInfo) { let result = textStr; streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); @@ -354,10 +328,10 @@ if (reloadPlayer) { postMessage({key:'UboReloadPlayer'}); } - postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll}); + updateAdblockBannerForStream(streamInfo); return result; } - function stripAdSegments(textStr, stripAllSegments) { + function stripAdSegments(textStr, stripAllSegments, streamInfo) { let hasStrippedAdSegments = false; const lines = textStr.replaceAll('\r', '').split('\n'); const newAdUrl = 'https://twitch.tv'; @@ -369,6 +343,9 @@ .replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`); if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) { const segmentUrl = lines[i + 1]; + if (!AdSegmentCache.has(segmentUrl)) { + streamInfo.NumStrippedAdSegments++; + } AdSegmentCache.set(segmentUrl, Date.now()); hasStrippedAdSegments = true; } @@ -383,16 +360,15 @@ lines[i] = ''; } } + } else { + streamInfo.NumStrippedAdSegments = 0; } + streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; 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) { @@ -417,7 +393,6 @@ streamInfo.BackupEncodings = null; streamInfo.BackupEncodingsStatus.clear(); streamInfo.BackupEncodingsPlayerTypeIndex = -1; - postMessage({key:'UboHideAdBanner'}); postMessage({key:'UboReloadPlayer'}); } else if (!streamM3u8.includes('"MIDROLL"') && !streamM3u8.includes('"midroll"')) { const lines = streamM3u8.replaceAll('\r', '').split('\n'); @@ -441,12 +416,11 @@ } } else if (haveAdTags && !streamInfo.IsMovingOffBackupEncodings) { textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url, currentResolution); - } else { - postMessage({key:'UboHideAdBanner'}); } if (IsAdStrippingEnabled) { - textStr = stripAdSegments(textStr); + textStr = stripAdSegments(textStr, false, streamInfo); } + updateAdblockBannerForStream(streamInfo); return textStr; } function hookWorkerFetch() { @@ -519,6 +493,8 @@ BackupEncodingsPlayerTypeIndex: -1, IsMovingOffBackupEncodings: false, IsMidroll: false, + IsStrippingAdSegments: false, + NumStrippedAdSegments: 0, UseFallbackStream: false, ChannelName: channelName, UsherParams: (new URL(url)).search, @@ -722,18 +698,19 @@ 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') && !init.body.includes('frontpage')) { + if (OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + const targetPlayerType = 'embed'; 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) { + if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType === targetPlayerType) { 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) { + if (newBody?.variables?.playerType && newBody?.variables?.playerType === targetPlayerType) { replacedPlayerType = newBody.variables.playerType; newBody.variables.playerType = OPT_FORCE_ACCESS_TOKEN_PLAYER_TYPE; } @@ -764,10 +741,42 @@ 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 updateAdblockBanner(data) { + const playerRootDiv = document.querySelector('.video-player'); + if (playerRootDiv != null) { + let adBlockDiv = null; + adBlockDiv = playerRootDiv.querySelector('.adblock-overlay'); + if (adBlockDiv == null) { + adBlockDiv = document.createElement('div'); + adBlockDiv.className = 'adblock-overlay'; + adBlockDiv.innerHTML = '

'; + adBlockDiv.style.display = 'none'; + adBlockDiv.P = adBlockDiv.querySelector('p'); + playerRootDiv.appendChild(adBlockDiv); + } + if (adBlockDiv != null) { + if (!twitchPlayerAndState?.player?.core || !twitchPlayerAndState?.state) { + twitchPlayerAndState = getPlayerAndState(); + } + const isLive = twitchPlayerAndState?.state?.props?.content?.type === 'live'; + adBlockDiv.P.textContent = 'Blocking' + (data.isMidroll ? ' midroll' : '') + ' ads' + (data.isStrippingAdSegments ? ' (stripping)' : '');// + (data.numStrippedAdSegments > 0 ? ` (${data.numStrippedAdSegments})` : ''); + adBlockDiv.style.display = data.hasAds && isLive ? 'block' : 'none'; + } + } + } + function monitorLiveStatus() { + if (!twitchPlayerAndState?.player?.core || !twitchPlayerAndState?.state) { + twitchPlayerAndState = getPlayerAndState(); + } + const isLive = twitchPlayerAndState?.state?.props?.content?.type === 'live'; + if (!isLive) { + updateAdblockBanner({ + hasAds: false + }); + } + setTimeout(monitorLiveStatus, 1000); + } + function getPlayerAndState() { function findReactNode(root, constraint) { if (root.stateNode && constraint(root.stateNode)) { return root.stateNode; @@ -788,7 +797,7 @@ if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { reactRootNode = rootNode._reactRootContainer._internalRoot.current; } - if (reactRootNode == null) { + if (reactRootNode == null && rootNode != null) { const containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer')); if (containerName != null) { reactRootNode = rootNode[containerName]; @@ -798,12 +807,24 @@ } const reactRootNode = findReactRootNode(); if (!reactRootNode) { - console.log('Could not find react root'); - return; + return null; } let player = findReactNode(reactRootNode, node => node.setPlayerActive && node.props && node.props.mediaPlayerInstance); player = player && player.props && player.props.mediaPlayerInstance ? player.props.mediaPlayerInstance : null; const playerState = findReactNode(reactRootNode, node => node.setSrc && node.setInitialPlaybackSettings); + return { + player: player, + state: playerState + }; + } + function reloadTwitchPlayer(isPausePlay) { + const playerAndState = getPlayerAndState(); + if (!playerAndState) { + console.log('Could not find react root'); + return; + } + const player = playerAndState.player; + const playerState = playerAndState.state; if (!player) { console.log('Could not find player'); return; @@ -954,6 +975,7 @@ declareOptions(window); hookWindowWorker(); hookFetch(); + monitorLiveStatus(); if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { onContentLoaded(); } else {