diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..882b4114 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,41 @@ +# Frosty fork — Twitch ad blocking (DESIGN) + +Personal Forgejo fork of [tommyxchow/frosty](https://github.com/tommyxchow/frosty) that adds **TwitchAdSolutions (vaft)**-style live ad blocking. + +Canonical living plan: session `active-plan.md` for conversation `37b527bf-e540-4753-be1f-39906fe2ba28`. ADR: **ADR-026**. + +## Problem + +Upstream Frosty plays Twitch HLS (native) or `player.twitch.tv` (WebView). Twitch may stitch ad pods into the media playlist (`stitched` / `EXT-X-DATERANGE`). Upstream only **detects** native ad breaks (to avoid bogus latency recovery); it does **not** block ads. Turbo/sub `hide_ads` via linked web session is separate and not a client blocker. + +## Approach (vaft-equivalent) + +Browser vaft hooks `Worker`/`fetch` and rewrites m3u8. Frosty’s native player cannot do that from Dart (`loadUrl` + headers only). + +| Layer | Behavior | +| --- | --- | +| Primary token | GQL `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) when Settings `blockTwitchAds` is on | +| Ad break | Native `adBreakStream` → probe backup types `embed` → `popout` → `autoplay`; accept first Usher multivariant whose sampled media playlist lacks `stitched`; `loadUrl` that URL | +| Ad end | Restore primary popout Usher URL | +| WebView | Best-effort inject of vendored `assets/js/vaft.user.js` (not true document-start on Android) | +| UI | Settings toggle (default on; enabling forces native player); overlay “Blocking ads” | + +## Key code + +- `lib/apis/twitch_ad_solutions.dart` — constants + `TwitchAdFreePlayback` +- `lib/apis/twitch_gql_api.dart` — `playerType` / `platform`, `fetchPlaylistText` +- `lib/screens/channel/video/native_video_store.dart` — load + swap +- `lib/screens/channel/video/video_store.dart` — WebView inject +- `lib/screens/settings/stores/settings_store.dart` — `blockTwitchAds` +- `assets/js/vaft.user.js` — upstream TwitchAdSolutions script + +## Non-goals (v1) + +- Upstream PR +- Full Worker prototype cleaning +- Third-party proxies +- HLS segment stripping inside `better_native_video_player` (unless smoke tests demand it) + +## Build + +Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`. diff --git a/assets/js/vaft.user.js b/assets/js/vaft.user.js new file mode 100644 index 00000000..47c3e971 --- /dev/null +++ b/assets/js/vaft.user.js @@ -0,0 +1,1119 @@ +// ==UserScript== +// @name TwitchAdSolutions (vaft) +// @namespace https://github.com/pixeltris/TwitchAdSolutions +// @version 37.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 +// @author https://github.com/cleanlock/VideoAdBlockForTwitch#credits +// @match *://*.twitch.tv/* +// @run-at document-start +// @inject-into page +// @grant none +// ==/UserScript== +(function() { + 'use strict'; + const ourTwitchAdSolutionsVersion = 24;// Used to prevent conflicts with outdated versions of the scripts + if (typeof window.twitchAdSolutionsVersion !== 'undefined' && window.twitchAdSolutionsVersion >= ourTwitchAdSolutionsVersion) { + console.log("skipping vaft as there's another script active. ourVersion:" + ourTwitchAdSolutionsVersion + " activeVersion:" + window.twitchAdSolutionsVersion); + window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + return; + } + window.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; + function declareOptions(scope) { + scope.AdSignifier = 'stitched'; + scope.ClientID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; + scope.BackupPlayerTypes = [ + 'embed',//Source + 'popout',//Source + 'autoplay',//360p + //'picture-by-picture-CACHED'//360p (-CACHED is an internal suffix and is removed) + ]; + scope.FallbackPlayerType = 'embed'; + scope.ForceAccessTokenPlayerType = 'popout'; + scope.SkipPlayerReloadOnHevc = false;// If true this will skip player reload on streams which have 2k/4k quality (if you enable this and you use the 2k/4k quality setting you'll get error #4000 / #3000 / spinning wheel on chrome based browsers) + scope.AlwaysReloadPlayerOnAd = false;// Always pause/play when entering/leaving ads + scope.ReloadPlayerAfterAd = true;// After the ad finishes do a player reload instead of pause/play + scope.PlayerReloadMinimalRequestsTime = 1500; + scope.PlayerReloadMinimalRequestsPlayerIndex = 2;//autoplay + scope.HasTriggeredPlayerReload = false; + scope.StreamInfos = []; + scope.StreamInfosByUrl = []; + scope.GQLDeviceID = null; + scope.ClientVersion = null; + scope.ClientSession = null; + scope.ClientIntegrityHeader = null; + scope.AuthorizationHeader = undefined; + scope.SimulatedAdsDepth = 0; + scope.PlayerBufferingFix = true;// If true this will pause/play the player when it gets stuck buffering + scope.PlayerBufferingDelay = 600;// How often should we check the player state (in milliseconds) + scope.PlayerBufferingSameStateCount = 3;// How many times of seeing the same player state until we trigger pause/play (it will only trigger it one time until the player state changes again) + scope.PlayerBufferingDangerZone = 1;// The buffering time left (in seconds) when we should ignore the players playback position in the player state check + scope.PlayerBufferingDoPlayerReload = false;// If true this will do a player reload instead of pause/play (player reloading is better at fixing the playback issues but it takes slightly longer) + scope.PlayerBufferingMinRepeatDelay = 8000;// Minimum delay (in milliseconds) between each pause/play (this is to avoid over pressing pause/play when there are genuine buffering problems) + scope.PlayerBufferingPrerollCheckEnabled = false;// Enable this if you're getting an immediate pause/play/reload as you open a stream (which is causing the stream to take longer to load). One problem with this being true is that it can cause the player to get stuck in some instances requiring the user to press pause/play + scope.PlayerBufferingPrerollCheckOffset = 5;// How far the stream need to move before doing the buffering mitigation (depends on PlayerBufferingPrerollCheckEnabled being true) + scope.V2API = false; + scope.IsAdStrippingEnabled = true; + scope.AdSegmentCache = new Map(); + scope.AllSegmentsAreAdSegments = false; + } + let isActivelyStrippingAds = false; + let localStorageHookFailed = false; + const twitchWorkers = []; + const workerStringConflicts = [ + 'twitch', + 'isVariantA'// TwitchNoSub + ]; + const workerStringAllow = []; + const workerStringReinsert = [ + 'isVariantA',// TwitchNoSub (prior to (0.9)) + 'besuper/',// TwitchNoSub (0.9) + '${patch_url}'// TwitchNoSub (0.9.1) + ]; + function getCleanWorker(worker) { + let root = null; + let parent = null; + let proto = worker; + while (proto) { + const workerString = proto.toString(); + if (workerStringConflicts.some((x) => workerString.includes(x)) && !workerStringAllow.some((x) => workerString.includes(x))) { + if (parent !== null) { + Object.setPrototypeOf(parent, Object.getPrototypeOf(proto)); + } + } else { + if (root === null) { + root = proto; + } + parent = proto; + } + proto = Object.getPrototypeOf(proto); + } + return root; + } + function getWorkersForReinsert(worker) { + const result = []; + let proto = worker; + while (proto) { + const workerString = proto.toString(); + if (workerStringReinsert.some((x) => workerString.includes(x))) { + result.push(proto); + } else { + } + proto = Object.getPrototypeOf(proto); + } + return result; + } + function reinsertWorkers(worker, reinsert) { + let parent = worker; + for (let i = 0; i < reinsert.length; i++) { + Object.setPrototypeOf(reinsert[i], parent); + parent = reinsert[i]; + } + return parent; + } + function isValidWorker(worker) { + const workerString = worker.toString(); + return !workerStringConflicts.some((x) => workerString.includes(x)) + || workerStringAllow.some((x) => workerString.includes(x)) + || workerStringReinsert.some((x) => workerString.includes(x)); + } + function hookWindowWorker() { + const reinsert = getWorkersForReinsert(window.Worker); + const newWorker = class Worker extends getCleanWorker(window.Worker) { + constructor(twitchBlobUrl, options) { + let isTwitchWorker = false; + try { + isTwitchWorker = new URL(twitchBlobUrl).origin.endsWith('.twitch.tv'); + } catch {} + if (!isTwitchWorker) { + super(twitchBlobUrl, options); + return; + } + const newBlobStr = ` + const pendingFetchRequests = new Map(); + ${stripAdSegments.toString()} + ${getStreamUrlForResolution.toString()} + ${processM3U8.toString()} + ${hookWorkerFetch.toString()} + ${declareOptions.toString()} + ${getAccessToken.toString()} + ${gqlRequest.toString()} + ${parseAttributes.toString()} + ${getWasmWorkerJs.toString()} + ${getServerTimeFromM3u8.toString()} + ${replaceServerTimeInM3u8.toString()} + const workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}'); + declareOptions(self); + GQLDeviceID = ${GQLDeviceID ? "'" + GQLDeviceID + "'" : null}; + AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : undefined}; + ClientIntegrityHeader = ${ClientIntegrityHeader ? "'" + ClientIntegrityHeader + "'" : null}; + ClientVersion = ${ClientVersion ? "'" + ClientVersion + "'" : null}; + ClientSession = ${ClientSession ? "'" + ClientSession + "'" : null}; + self.addEventListener('message', function(e) { + if (e.data.key == 'UpdateClientVersion') { + ClientVersion = e.data.value; + } else if (e.data.key == 'UpdateClientSession') { + ClientSession = e.data.value; + } else if (e.data.key == 'UpdateClientId') { + ClientID = e.data.value; + } else if (e.data.key == 'UpdateDeviceId') { + GQLDeviceID = e.data.value; + } else if (e.data.key == 'UpdateClientIntegrityHeader') { + ClientIntegrityHeader = e.data.value; + } else if (e.data.key == 'UpdateAuthorizationHeader') { + AuthorizationHeader = e.data.value; + } else if (e.data.key == 'FetchResponse') { + const responseData = e.data.value; + if (pendingFetchRequests.has(responseData.id)) { + const { resolve, reject } = pendingFetchRequests.get(responseData.id); + pendingFetchRequests.delete(responseData.id); + if (responseData.error) { + reject(new Error(responseData.error)); + } else { + // Create a Response object from the response data + const response = new Response(responseData.body, { + status: responseData.status, + statusText: responseData.statusText, + headers: responseData.headers + }); + resolve(response); + } + } + } else if (e.data.key == 'TriggeredPlayerReload') { + HasTriggeredPlayerReload = true; + } else if (e.data.key == 'SimulateAds') { + SimulatedAdsDepth = e.data.value; + console.log('SimulatedAdsDepth: ' + SimulatedAdsDepth); + } else if (e.data.key == 'AllSegmentsAreAdSegments') { + AllSegmentsAreAdSegments = !AllSegmentsAreAdSegments; + console.log('AllSegmentsAreAdSegments: ' + AllSegmentsAreAdSegments); + } + }); + hookWorkerFetch(); + eval(workerString); + `; + super(URL.createObjectURL(new Blob([newBlobStr])), options); + twitchWorkers.push(this); + this.addEventListener('message', (e) => { + if (e.data.key == 'UpdateAdBlockBanner') { + updateAdblockBanner(e.data); + } else if (e.data.key == 'PauseResumePlayer') { + doTwitchPlayerTask(true, false); + } else if (e.data.key == 'ReloadPlayer') { + doTwitchPlayerTask(false, true); + } + }); + this.addEventListener('message', async event => { + if (event.data.key == 'FetchRequest') { + const fetchRequest = event.data.value; + const responseData = await handleWorkerFetchRequest(fetchRequest); + this.postMessage({ + key: 'FetchResponse', + value: responseData + }); + } + }); + } + }; + let workerInstance = reinsertWorkers(newWorker, reinsert); + Object.defineProperty(window, 'Worker', { + get: function() { + return workerInstance; + }, + set: function(value) { + if (isValidWorker(value)) { + workerInstance = value; + } else { + console.log('Attempt to set twitch worker denied'); + } + } + }); + } + function getWasmWorkerJs(twitchBlobUrl) { + const req = new XMLHttpRequest(); + req.open('GET', twitchBlobUrl, false); + req.overrideMimeType("text/javascript"); + req.send(); + return req.responseText; + } + function hookWorkerFetch() { + console.log('hookWorkerFetch (vaft)'); + const realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (AdSegmentCache.has(url)) { + return new Promise(function(resolve, reject) { + const send = function() { + return realFetch('data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA', options).then(function(response) { + resolve(response); + })['catch'](function(err) { + reject(err); + }); + }; + send(); + }); + } + url = url.trimEnd(); + if (url.endsWith('m3u8')) { + return new Promise(function(resolve, reject) { + const processAfter = async function(response) { + if (response.status === 200) { + resolve(new Response(await processM3U8(url, await response.text(), realFetch))); + } else { + resolve(response); + } + }; + const send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + reject(err); + }); + }; + send(); + }); + } else if (url.includes('/channel/hls/') && !url.includes('picture-by-picture')) { + V2API = url.includes('/api/v2/'); + const channelName = (new URL(url)).pathname.match(/([^\/]+)(?=\.\w+$)/)[0]; + if (ForceAccessTokenPlayerType) { + // parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads + const tempUrl = new URL(url); + tempUrl.searchParams.delete('parent_domains'); + url = tempUrl.toString(); + } + return new Promise(function(resolve, reject) { + const processAfter = async function(response) { + if (response.status == 200) { + const encodingsM3u8 = await response.text(); + const serverTime = getServerTimeFromM3u8(encodingsM3u8); + let streamInfo = StreamInfos[channelName]; + if (streamInfo != null && streamInfo.EncodingsM3U8 != null && (await realFetch(streamInfo.EncodingsM3U8.match(/^https:.*\.m3u8$/m)[0])).status !== 200) { + // The cached encodings are dead (the stream probably restarted) + streamInfo = null; + } + if (streamInfo == null || streamInfo.EncodingsM3U8 == null) { + StreamInfos[channelName] = streamInfo = { + ChannelName: channelName, + IsShowingAd: false, + LastPlayerReload: 0, + EncodingsM3U8: encodingsM3u8, + ModifiedM3U8: null, + IsUsingModifiedM3U8: false, + UsherParams: (new URL(url)).search, + RequestedAds: new Set(), + Urls: [],// xxx.m3u8 -> { Resolution: "284x160", FrameRate: 30.0 } + ResolutionList: [], + BackupEncodingsM3U8Cache: [], + ActiveBackupPlayerType: null, + IsMidroll: false, + IsStrippingAdSegments: false, + NumStrippedAdSegments: 0 + }; + const lines = encodingsM3u8.replaceAll('\r', '').split('\n'); + for (let i = 0; i < lines.length - 1; i++) { + if (lines[i].startsWith('#EXT-X-STREAM-INF') && lines[i + 1].includes('.m3u8')) { + const attributes = parseAttributes(lines[i]); + const resolution = attributes['RESOLUTION']; + if (resolution) { + const resolutionInfo = { + Resolution: resolution, + FrameRate: attributes['FRAME-RATE'], + Codecs: attributes['CODECS'], + Url: lines[i + 1] + }; + streamInfo.Urls[lines[i + 1]] = resolutionInfo; + streamInfo.ResolutionList.push(resolutionInfo); + } + StreamInfosByUrl[lines[i + 1]] = streamInfo; + } + } + const nonHevcResolutionList = streamInfo.ResolutionList.filter((element) => element.Codecs.startsWith('avc') || element.Codecs.startsWith('av0')); + if (AlwaysReloadPlayerOnAd || (nonHevcResolutionList.length > 0 && streamInfo.ResolutionList.some((element) => element.Codecs.startsWith('hev') || element.Codecs.startsWith('hvc')) && !SkipPlayerReloadOnHevc)) { + if (nonHevcResolutionList.length > 0) { + for (let i = 0; i < lines.length - 1; i++) { + if (lines[i].startsWith('#EXT-X-STREAM-INF')) { + const resSettings = parseAttributes(lines[i].substring(lines[i].indexOf(':') + 1)); + const codecsKey = 'CODECS'; + if (resSettings[codecsKey].startsWith('hev') || resSettings[codecsKey].startsWith('hvc')) { + const oldResolution = resSettings['RESOLUTION']; + const [targetWidth, targetHeight] = oldResolution.split('x').map(Number); + const newResolutionInfo = nonHevcResolutionList.sort((a, b) => { + // TODO: Take into account 'Frame-Rate' when sorting (i.e. 1080p60 vs 1080p30) + const [streamWidthA, streamHeightA] = a.Resolution.split('x').map(Number); + const [streamWidthB, streamHeightB] = b.Resolution.split('x').map(Number); + return Math.abs((streamWidthA * streamHeightA) - (targetWidth * targetHeight)) - Math.abs((streamWidthB * streamHeightB) - (targetWidth * targetHeight)); + })[0]; + console.log('ModifiedM3U8 swap ' + resSettings[codecsKey] + ' to ' + newResolutionInfo.Codecs + ' oldRes:' + oldResolution + ' newRes:' + newResolutionInfo.Resolution); + lines[i] = lines[i].replace(/CODECS="[^"]+"/, `CODECS="${newResolutionInfo.Codecs}"`); + lines[i + 1] = newResolutionInfo.Url + ' '.repeat(i + 1);// The stream doesn't load unless each url line is unique + } + } + } + } + if (nonHevcResolutionList.length > 0 || AlwaysReloadPlayerOnAd) { + streamInfo.ModifiedM3U8 = lines.join('\n'); + } + } + } + streamInfo.LastPlayerReload = Date.now(); + resolve(new Response(replaceServerTimeInM3u8(streamInfo.IsUsingModifiedM3U8 ? streamInfo.ModifiedM3U8 : streamInfo.EncodingsM3U8, serverTime))); + } else { + resolve(response); + } + }; + const send = function() { + return realFetch(url, options).then(function(response) { + processAfter(response); + })['catch'](function(err) { + reject(err); + }); + }; + send(); + }); + } + } + return realFetch.apply(this, arguments); + }; + } + function getServerTimeFromM3u8(encodingsM3u8) { + if (V2API) { + const matches = encodingsM3u8.match(/#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE="([^"]+)"/); + return matches.length > 1 ? matches[1] : null; + } + const matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"'); + return matches.length > 1 ? matches[1] : null; + } + function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) { + if (V2API) { + return newServerTime ? encodingsM3u8.replace(/(#EXT-X-SESSION-DATA:DATA-ID="SERVER-TIME",VALUE=")[^"]+(")/, `$1${newServerTime}$2`) : encodingsM3u8; + } + return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8; + } + function stripAdSegments(textStr, stripAllSegments, streamInfo) { + let hasStrippedAdSegments = false; + const lines = textStr.replaceAll('\r', '').split('\n'); + const newAdUrl = 'https://twitch.tv'; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + // Remove tracking urls which appear in the overlay UI + line = line + .replaceAll(/(X-TV-TWITCH-AD-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`) + .replaceAll(/(X-TV-TWITCH-AD-CLICK-TRACKING-URL=")(?:[^"]*)(")/g, `$1${newAdUrl}$2`); + if (i < lines.length - 1 && line.startsWith('#EXTINF') && (!line.includes(',live') || stripAllSegments || AllSegmentsAreAdSegments)) { + const segmentUrl = lines[i + 1]; + if (!AdSegmentCache.has(segmentUrl)) { + streamInfo.NumStrippedAdSegments++; + } + AdSegmentCache.set(segmentUrl, Date.now()); + hasStrippedAdSegments = true; + } + if (line.includes(AdSignifier)) { + hasStrippedAdSegments = true; + } + } + if (hasStrippedAdSegments) { + for (let i = 0; i < lines.length; i++) { + // No low latency during ads (otherwise it's possible for the player to prefetch and display ad segments) + if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) { + lines[i] = ''; + } + } + } else { + streamInfo.NumStrippedAdSegments = 0; + } + streamInfo.IsStrippingAdSegments = hasStrippedAdSegments; + AdSegmentCache.forEach((value, key, map) => { + if (value < Date.now() - 120000) { + map.delete(key); + } + }); + return lines.join('\n'); + } + function getStreamUrlForResolution(encodingsM3u8, resolutionInfo) { + const encodingsLines = encodingsM3u8.replaceAll('\r', '').split('\n'); + const [targetWidth, targetHeight] = resolutionInfo.Resolution.split('x').map(Number); + let matchedResolutionUrl = null; + let matchedFrameRate = false; + let closestResolutionUrl = null; + let closestResolutionDifference = Infinity; + for (let i = 0; i < encodingsLines.length - 1; i++) { + if (encodingsLines[i].startsWith('#EXT-X-STREAM-INF') && encodingsLines[i + 1].includes('.m3u8')) { + const attributes = parseAttributes(encodingsLines[i]); + const resolution = attributes['RESOLUTION']; + const frameRate = attributes['FRAME-RATE']; + if (resolution) { + if (resolution == resolutionInfo.Resolution && (!matchedResolutionUrl || (!matchedFrameRate && frameRate == resolutionInfo.FrameRate))) { + matchedResolutionUrl = encodingsLines[i + 1]; + matchedFrameRate = frameRate == resolutionInfo.FrameRate; + if (matchedFrameRate) { + return matchedResolutionUrl; + } + } + const [width, height] = resolution.split('x').map(Number); + const difference = Math.abs((width * height) - (targetWidth * targetHeight)); + if (difference < closestResolutionDifference) { + closestResolutionUrl = encodingsLines[i + 1]; + closestResolutionDifference = difference; + } + } + } + } + return closestResolutionUrl; + } + async function processM3U8(url, textStr, realFetch) { + const streamInfo = StreamInfosByUrl[url]; + if (!streamInfo) { + return textStr; + } + if (HasTriggeredPlayerReload) { + HasTriggeredPlayerReload = false; + streamInfo.LastPlayerReload = Date.now(); + } + const haveAdTags = textStr.includes(AdSignifier) || SimulatedAdsDepth > 0; + if (haveAdTags) { + streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"'); + if (!streamInfo.IsShowingAd) { + streamInfo.IsShowingAd = true; + postMessage({ + key: 'UpdateAdBlockBanner', + isMidroll: streamInfo.IsMidroll, + hasAds: streamInfo.IsShowingAd, + isStrippingAdSegments: false + }); + } + if (!streamInfo.IsMidroll) { + const lines = textStr.replaceAll('\r', '').split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.startsWith('#EXTINF') && lines.length > i + 1) { + if (!line.includes(',live') && !streamInfo.RequestedAds.has(lines[i + 1])) { + // Only request one .ts file per .m3u8 request to avoid making too many requests + //console.log('Fetch ad .ts file'); + streamInfo.RequestedAds.add(lines[i + 1]); + fetch(lines[i + 1]).then((response)=>{response.blob()}); + break; + } + } + } + } + const currentResolution = streamInfo.Urls[url]; + if (!currentResolution) { + console.log('Ads will leak due to missing resolution info for ' + url); + return textStr; + } + const isHevc = currentResolution.Codecs.startsWith('hev') || currentResolution.Codecs.startsWith('hvc'); + if (((isHevc && !SkipPlayerReloadOnHevc) || AlwaysReloadPlayerOnAd) && streamInfo.ModifiedM3U8 && !streamInfo.IsUsingModifiedM3U8) { + streamInfo.IsUsingModifiedM3U8 = true; + streamInfo.LastPlayerReload = Date.now(); + postMessage({ + key: 'ReloadPlayer' + }); + } + let backupPlayerType = null; + let backupM3u8 = null; + let fallbackM3u8 = null; + let startIndex = 0; + let isDoingMinimalRequests = false; + if (streamInfo.LastPlayerReload > Date.now() - PlayerReloadMinimalRequestsTime) { + // When doing player reload there are a lot of requests which causes the backup stream to load in slow. Briefly prefer using a single version to prevent long delays + startIndex = PlayerReloadMinimalRequestsPlayerIndex; + isDoingMinimalRequests = true; + } + for (let playerTypeIndex = startIndex; !backupM3u8 && playerTypeIndex < BackupPlayerTypes.length; playerTypeIndex++) { + const playerType = BackupPlayerTypes[playerTypeIndex]; + const realPlayerType = playerType.replace('-CACHED', ''); + const isFullyCachedPlayerType = playerType != realPlayerType; + for (let i = 0; i < 2; i++) { + // This caches the m3u8 if it doesn't have ads. If the already existing cache has ads it fetches a new version (second loop) + let isFreshM3u8 = false; + let encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType]; + if (!encodingsM3u8) { + isFreshM3u8 = true; + try { + const accessTokenResponse = await getAccessToken(streamInfo.ChannelName, realPlayerType); + if (accessTokenResponse.status === 200) { + const accessToken = await accessTokenResponse.json(); + const urlInfo = new URL('https://usher.ttvnw.net/api/' + (V2API ? 'v2/' : '') + 'channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.UsherParams); + urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature); + urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value); + const encodingsM3u8Response = await realFetch(urlInfo.href); + if (encodingsM3u8Response.status === 200) { + encodingsM3u8 = streamInfo.BackupEncodingsM3U8Cache[playerType] = await encodingsM3u8Response.text(); + } + } + } catch (err) {} + } + if (encodingsM3u8) { + try { + const streamM3u8Url = getStreamUrlForResolution(encodingsM3u8, currentResolution); + const streamM3u8Response = await realFetch(streamM3u8Url); + if (streamM3u8Response.status == 200) { + const m3u8Text = await streamM3u8Response.text(); + if (m3u8Text) { + if (playerType == FallbackPlayerType) { + fallbackM3u8 = m3u8Text; + } + if ((!m3u8Text.includes(AdSignifier) && (SimulatedAdsDepth == 0 || playerTypeIndex >= SimulatedAdsDepth - 1)) || (!fallbackM3u8 && playerTypeIndex >= BackupPlayerTypes.length - 1)) { + backupPlayerType = playerType; + backupM3u8 = m3u8Text; + break; + } + if (isFullyCachedPlayerType) { + break; + } + if (isDoingMinimalRequests) { + backupPlayerType = playerType; + backupM3u8 = m3u8Text; + break; + } + } + } + } catch (err) {} + } + streamInfo.BackupEncodingsM3U8Cache[playerType] = null; + if (isFreshM3u8) { + break; + } + } + } + if (!backupM3u8 && fallbackM3u8) { + backupPlayerType = FallbackPlayerType; + backupM3u8 = fallbackM3u8; + } + if (backupM3u8) { + textStr = backupM3u8; + if (streamInfo.ActiveBackupPlayerType != backupPlayerType) { + streamInfo.ActiveBackupPlayerType = backupPlayerType; + console.log(`Blocking${(streamInfo.IsMidroll ? ' midroll ' : ' ')}ads (${backupPlayerType})`); + } + } + // TODO: Improve hevc stripping. It should always strip when there is a codec mismatch (both ways) + const stripHevc = isHevc && streamInfo.ModifiedM3U8; + if (IsAdStrippingEnabled || stripHevc) { + textStr = stripAdSegments(textStr, stripHevc, streamInfo); + } + } else if (streamInfo.IsShowingAd) { + console.log('Finished blocking ads'); + streamInfo.IsShowingAd = false; + streamInfo.IsStrippingAdSegments = false; + streamInfo.NumStrippedAdSegments = 0; + streamInfo.ActiveBackupPlayerType = null; + if (streamInfo.IsUsingModifiedM3U8 || ReloadPlayerAfterAd) { + streamInfo.IsUsingModifiedM3U8 = false; + streamInfo.LastPlayerReload = Date.now(); + postMessage({ + key: 'ReloadPlayer' + }); + } else { + postMessage({ + key: 'PauseResumePlayer' + }); + } + } + postMessage({ + key: 'UpdateAdBlockBanner', + isMidroll: streamInfo.IsMidroll, + hasAds: streamInfo.IsShowingAd, + isStrippingAdSegments: streamInfo.IsStrippingAdSegments, + numStrippedAdSegments: streamInfo.NumStrippedAdSegments + }); + return textStr; + } + function parseAttributes(str) { + return Object.fromEntries( + str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) + .filter(Boolean) + .map(x => { + const idx = x.indexOf('='); + const key = x.substring(0, idx); + const value = x.substring(idx + 1); + const num = Number(value); + return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num]; + })); + } + function getAccessToken(channelName, playerType) { + const body = { + operationName: 'PlaybackAccessToken', + variables: { + isLive: true, + login: channelName, + isVod: false, + vodID: "", + playerType: playerType, + platform: playerType == 'autoplay' ? 'android' : 'web' + }, + extensions: { + persistedQuery: { + version:1, + sha256Hash:"ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9" + } + } + }; + return gqlRequest(body, playerType); + } + function gqlRequest(body, playerType) { + if (!GQLDeviceID) { + GQLDeviceID = ''; + const dcharacters = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const dcharactersLength = dcharacters.length; + for (let i = 0; i < 32; i++) { + GQLDeviceID += dcharacters.charAt(Math.floor(Math.random() * dcharactersLength)); + } + } + let headers = { + 'Client-ID': ClientID, + 'X-Device-Id': GQLDeviceID, + 'Authorization': AuthorizationHeader, + ...(ClientIntegrityHeader && {'Client-Integrity': ClientIntegrityHeader}), + ...(ClientVersion && {'Client-Version': ClientVersion}), + ...(ClientSession && {'Client-Session-Id': ClientSession}) + }; + return new Promise((resolve, reject) => { + const requestId = Math.random().toString(36).substring(2, 15); + const fetchRequest = { + id: requestId, + url: 'https://gql.twitch.tv/gql', + options: { + method: 'POST', + body: JSON.stringify(body), + headers + } + }; + pendingFetchRequests.set(requestId, { + resolve, + reject + }); + postMessage({ + key: 'FetchRequest', + value: fetchRequest + }); + }); + } + let playerForMonitoringBuffering = null; + const playerBufferState = { + channelName: null, + hasStreamStarted: false, + position: 0, + bufferedPosition: 0, + bufferDuration: 0, + numSame: 0, + lastFixTime: 0, + isLive: true + }; + function monitorPlayerBuffering() { + if (playerForMonitoringBuffering) { + try { + const player = playerForMonitoringBuffering.player; + const state = playerForMonitoringBuffering.state; + if (!player.core) { + playerForMonitoringBuffering = null; + } else if (state.props?.content?.type === 'live' && !player.isPaused() && !player.getHTMLVideoElement()?.ended && playerBufferState.lastFixTime <= Date.now() - PlayerBufferingMinRepeatDelay && !isActivelyStrippingAds) { + const m3u8Url = player.core?.state?.path; + if (m3u8Url) { + const fileName = new URL(m3u8Url).pathname.split('/').pop(); + if (fileName?.endsWith('.m3u8')) { + const channelName = fileName.slice(0, -5); + if (playerBufferState.channelName != channelName) { + playerBufferState.channelName = channelName; + playerBufferState.hasStreamStarted = false; + playerBufferState.numSame = 0; + //console.log('Channel changed to ' + channelName); + } + } + } + if (player.getState() === 'Playing') { + playerBufferState.hasStreamStarted = true; + } + const position = player.core?.state?.position; + const bufferedPosition = player.core?.state?.bufferedPosition; + const bufferDuration = player.getBufferDuration(); + if (position !== undefined && bufferedPosition !== undefined) { + //console.log('position:' + position + ' bufferDuration:' + bufferDuration + ' bufferPosition:' + bufferedPosition + ' state: ' + player.core?.state?.state + ' started: ' + playerBufferState.hasStreamStarted); + // NOTE: This could be improved. It currently lets the player fully eat the full buffer before it triggers pause/play + if (playerBufferState.hasStreamStarted && + (!PlayerBufferingPrerollCheckEnabled || position > PlayerBufferingPrerollCheckOffset) && + (playerBufferState.position == position || bufferDuration < PlayerBufferingDangerZone) && + playerBufferState.bufferedPosition == bufferedPosition && + playerBufferState.bufferDuration >= bufferDuration && + (position != 0 || bufferedPosition != 0 || bufferDuration != 0) + ) { + playerBufferState.numSame++; + if (playerBufferState.numSame == PlayerBufferingSameStateCount) { + console.log('Attempt to fix buffering position:' + playerBufferState.position + ' bufferedPosition:' + playerBufferState.bufferedPosition + ' bufferDuration:' + playerBufferState.bufferDuration); + const isPausePlay = !PlayerBufferingDoPlayerReload; + const isReload = PlayerBufferingDoPlayerReload; + doTwitchPlayerTask(isPausePlay, isReload); + playerBufferState.lastFixTime = Date.now(); + playerBufferState.numSame = 0; + } + } else { + playerBufferState.numSame = 0; + } + playerBufferState.position = position; + playerBufferState.bufferedPosition = bufferedPosition; + playerBufferState.bufferDuration = bufferDuration; + } else { + playerBufferState.numSame = 0; + } + } + } catch (err) { + console.error('error when monitoring player for buffering: ' + err); + playerForMonitoringBuffering = null; + } + } + if (!playerForMonitoringBuffering) { + const playerAndState = getPlayerAndState(); + if (playerAndState && playerAndState.player && playerAndState.state) { + playerForMonitoringBuffering = { + player: playerAndState.player, + state: playerAndState.state + }; + } + } + const isLive = playerForMonitoringBuffering?.state?.props?.content?.type === 'live'; + if (playerBufferState.isLive && !isLive) { + updateAdblockBanner({ + hasAds: false + }); + } + playerBufferState.isLive = isLive; + setTimeout(monitorPlayerBuffering, PlayerBufferingDelay); + } + function updateAdblockBanner(data) { + const playerRootDiv = document.querySelector('.video-player'); + if (playerRootDiv != null) { + let adBlockDiv = null; + adBlockDiv = playerRootDiv.querySelector('.adblock-overlay'); + if (adBlockDiv == null) { + adBlockDiv = document.createElement('div'); + adBlockDiv.className = 'adblock-overlay'; + adBlockDiv.innerHTML = '

'; + 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; + } + let node = root.child; + while (node) { + const result = findReactNode(node, constraint); + if (result) { + return result; + } + node = node.sibling; + } + return null; + } + function findReactRootNode() { + let reactRootNode = null; + const rootNode = document.querySelector('#root'); + if (rootNode && rootNode._reactRootContainer && rootNode._reactRootContainer._internalRoot && rootNode._reactRootContainer._internalRoot.current) { + reactRootNode = rootNode._reactRootContainer._internalRoot.current; + } + if (reactRootNode == null && rootNode != null) { + const containerName = Object.keys(rootNode).find(x => x.startsWith('__reactContainer')); + if (containerName != null) { + reactRootNode = rootNode[containerName]; + } + } + return reactRootNode; + } + const reactRootNode = findReactRootNode(); + if (!reactRootNode) { + 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; + if (player?.playerInstance) { + player = player.playerInstance; + } + 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; + } + if (!playerState) { + console.log('Could not find player state'); + return; + } + if (player.isPaused() || player.core?.paused) { + return; + } + playerBufferState.lastFixTime = Date.now(); + playerBufferState.numSame = 0; + if (isPausePlay) { + player.pause(); + player.play(); + return; + } + if (isReload) { + const lsKeyQuality = 'video-quality'; + const lsKeyMuted = 'video-muted'; + const lsKeyVolume = 'volume'; + let currentQualityLS = null; + let currentMutedLS = null; + let currentVolumeLS = null; + try { + currentQualityLS = localStorage.getItem(lsKeyQuality); + currentMutedLS = localStorage.getItem(lsKeyMuted); + currentVolumeLS = localStorage.getItem(lsKeyVolume); + if (localStorageHookFailed && player?.core?.state) { + localStorage.setItem(lsKeyMuted, JSON.stringify({default:player.core.state.muted})); + localStorage.setItem(lsKeyVolume, player.core.state.volume); + } + if (localStorageHookFailed && player?.core?.state?.quality?.group) { + localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group})); + } + } catch {} + console.log('Reloading Twitch player'); + playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true }); + postTwitchWorkerMessage('TriggeredPlayerReload'); + player.play(); + if (localStorageHookFailed && (currentQualityLS || currentMutedLS || currentVolumeLS)) { + setTimeout(() => { + try { + if (currentQualityLS) { + localStorage.setItem(lsKeyQuality, currentQualityLS); + } + if (currentMutedLS) { + localStorage.setItem(lsKeyMuted, currentMutedLS); + } + if (currentVolumeLS) { + localStorage.setItem(lsKeyVolume, currentVolumeLS); + } + } catch {} + }, 3000); + } + return; + } + } + window.reloadTwitchPlayer = () => { + doTwitchPlayerTask(false, true); + }; + function postTwitchWorkerMessage(key, value) { + twitchWorkers.forEach((worker) => { + worker.postMessage({key: key, value: value}); + }); + } + async function handleWorkerFetchRequest(fetchRequest) { + try { + 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, + error: error.message + }; + } + } + function hookFetch() { + const realFetch = window.fetch; + window.realFetch = realFetch; + window.fetch = function(url, init, ...args) { + if (typeof url === 'string') { + if (url.includes('gql')) { + let deviceId = init.headers['X-Device-Id']; + if (typeof deviceId !== 'string') { + deviceId = init.headers['Device-ID']; + } + if (typeof deviceId === 'string' && GQLDeviceID != deviceId) { + GQLDeviceID = deviceId; + postTwitchWorkerMessage('UpdateDeviceId', GQLDeviceID); + } + if (typeof init.headers['Client-Version'] === 'string' && init.headers['Client-Version'] !== ClientVersion) { + postTwitchWorkerMessage('UpdateClientVersion', ClientVersion = init.headers['Client-Version']); + } + if (typeof init.headers['Client-Session-Id'] === 'string' && init.headers['Client-Session-Id'] !== ClientSession) { + postTwitchWorkerMessage('UpdateClientSession', ClientSession = init.headers['Client-Session-Id']); + } + 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']); + } + // Get rid of mini player above chat - TODO: Reject this locally instead of having server reject it + if (init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && init.body.includes('picture-by-picture')) { + init.body = ''; + } + if (ForceAccessTokenPlayerType && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { + let replacedPlayerType = ''; + const newBody = JSON.parse(init.body); + if (Array.isArray(newBody)) { + for (let i = 0; i < newBody.length; i++) { + if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType !== ForceAccessTokenPlayerType) { + replacedPlayerType = newBody[i].variables.playerType; + newBody[i].variables.playerType = ForceAccessTokenPlayerType; + } + } + } else { + if (newBody?.variables?.playerType && newBody?.variables?.playerType !== ForceAccessTokenPlayerType) { + replacedPlayerType = newBody.variables.playerType; + newBody.variables.playerType = ForceAccessTokenPlayerType; + } + } + if (replacedPlayerType) { + console.log(`Replaced '${replacedPlayerType}' player type with '${ForceAccessTokenPlayerType}' player type`); + init.body = JSON.stringify(newBody); + } + } + } + } + return realFetch.apply(this, arguments); + }; + } + function onContentLoaded() { + // This stops Twitch from pausing the player when in another tab and an ad shows. + // Taken from https://github.com/saucettv/VideoAdBlockForTwitch/blob/cefce9d2b565769c77e3666ac8234c3acfe20d83/chrome/content.js#L30 + try { + Object.defineProperty(document, 'visibilityState', { + get() { + return 'visible'; + } + }); + }catch{} + let hidden = document.__lookupGetter__('hidden'); + let webkitHidden = document.__lookupGetter__('webkitHidden'); + try { + Object.defineProperty(document, 'hidden', { + get() { + return false; + } + }); + }catch{} + const block = e => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + }; + let wasVideoPlaying = true; + const visibilityChange = e => { + const isChrome = typeof chrome !== 'undefined'; + const videos = document.getElementsByTagName('video'); + if (videos.length > 0) { + if (hidden.apply(document) === true || (webkitHidden && webkitHidden.apply(document) === true)) { + wasVideoPlaying = !videos[0].paused && !videos[0].ended; + } else { + if (!playerBufferState.hasStreamStarted) { + //console.log('Tab focused. Stream should be active'); + playerBufferState.hasStreamStarted = true; + } + if (isChrome && wasVideoPlaying && !videos[0].ended && videos[0].paused && videos[0].muted) { + videos[0].play(); + } + } + } + block(e); + }; + document.addEventListener('visibilitychange', visibilityChange, true); + document.addEventListener('webkitvisibilitychange', visibilityChange, true); + document.addEventListener('mozvisibilitychange', visibilityChange, true); + document.addEventListener('hasFocus', block, true); + try { + if (/Firefox/.test(navigator.userAgent)) { + Object.defineProperty(document, 'mozHidden', { + get() { + return false; + } + }); + } else { + Object.defineProperty(document, 'webkitHidden', { + get() { + return false; + } + }); + } + }catch{} + // Hooks for preserving volume / resolution + try { + const keysToCache = [ + 'video-quality', + 'video-muted', + 'volume', + 'lowLatencyModeEnabled',// Low Latency + 'persistenceEnabled',// Mini Player + ]; + const cachedValues = new Map(); + for (let i = 0; i < keysToCache.length; i++) { + cachedValues.set(keysToCache[i], localStorage.getItem(keysToCache[i])); + } + const realSetItem = localStorage.setItem; + localStorage.setItem = function(key, value) { + if (cachedValues.has(key)) { + cachedValues.set(key, value); + } + realSetItem.apply(this, arguments); + }; + const realGetItem = localStorage.getItem; + localStorage.getItem = function(key) { + if (cachedValues.has(key)) { + return cachedValues.get(key); + } + return realGetItem.apply(this, arguments); + }; + if (!localStorage.getItem.toString().includes(Object.keys({cachedValues})[0])) { + // These hooks are useful to preserve player state on player reload + // Firefox doesn't allow hooking of localStorage functions but chrome does + localStorageHookFailed = true; + } + } catch (err) { + console.log('localStorageHooks failed ' + err) + localStorageHookFailed = true; + } + } + declareOptions(window); + hookWindowWorker(); + hookFetch(); + if (PlayerBufferingFix) { + monitorPlayerBuffering(); + } + if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") { + onContentLoaded(); + } else { + window.addEventListener("DOMContentLoaded", function() { + onContentLoaded(); + }); + } + window.simulateAds = (depth) => { + if (depth === undefined || depth < 0) { + console.log('Ad depth paramter required (0 = no simulated ad, 1+ = use backup player for given depth)'); + return; + } + postTwitchWorkerMessage('SimulateAds', depth); + }; + window.allSegmentsAreAdSegments = () => { + postTwitchWorkerMessage('AllSegmentsAreAdSegments'); + }; +})(); diff --git a/docs/DESIGN-adblock.md b/docs/DESIGN-adblock.md new file mode 100644 index 00000000..b226d01f --- /dev/null +++ b/docs/DESIGN-adblock.md @@ -0,0 +1,35 @@ +# Design: Twitch ad blocking (vaft) in Frosty fork + +## Intent + +Personal Forgejo fork of Frosty blocks Twitch live ads using [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions) ideas, adapted to Flutter. + +## Upstream baseline + +Frosty **did not** ship an adblocker. The native player only **detects** stitched ad breaks (`adBreakStream`) to avoid bad latency/stall recovery. Turbo/sub may get `hide_ads` via linked web GQL session — server-side, not client blocking. + +## Strategy + +### Native player (primary — this is the reliable path) + +1. When `SettingsStore.blockTwitchAds` is true, request `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) instead of `site`. +2. When the native plugin reports an ad break, resolve a backup Usher URL trying `embed` → `popout` → `autoplay`, preferring playlists whose media m3u8 does not contain `stitched`, then `loadUrl` that stream. +3. When the ad break ends, restore the primary popout URL. +4. Overlay shows “Blocking ads” while `_blockingAds` is true. + +Implementation: `lib/apis/twitch_ad_solutions.dart`, `lib/apis/twitch_gql_api.dart`, `lib/screens/channel/video/native_video_store.dart`. + +### WebView player (legacy, best-effort) + +Inject vendored `assets/js/vaft.user.js` on page start/finish. Not true `@run-at document-start` on Android via `webview_flutter`, so native remains preferred. Enabling adblock in Settings forces native on. + +## Settings + +- `blockTwitchAds` (default `true`) +- Turning adblock on sets `useNativePlayer = true` + +## Non-goals + +- Upstream PR +- Full browser Worker prototype rewriting on native +- Third-party CDN proxies unless player-type swap proves insufficient diff --git a/lib/apis/twitch_ad_solutions.dart b/lib/apis/twitch_ad_solutions.dart new file mode 100644 index 00000000..1647f4f0 --- /dev/null +++ b/lib/apis/twitch_ad_solutions.dart @@ -0,0 +1,163 @@ +import 'package:flutter/foundation.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/models/playback_access_token.dart'; + +/// Dart-side port of the [TwitchAdSolutions vaft](https://github.com/pixeltris/TwitchAdSolutions) +/// strategy for Frosty's native HLS player. +/// +/// Browser vaft hooks `Worker`/`fetch` to rewrite playlists. Here we instead: +/// 1. Request playback tokens with a non-`site` [forceAccessTokenPlayerType] +/// (vaft's `ForceAccessTokenPlayerType`). +/// 2. When stitched ads still appear, try [backupPlayerTypes] until a playlist +/// without the ad signifier is found, then hand that Usher URL to the player. +class TwitchAdSolutions { + TwitchAdSolutions._(); + + /// Matches vaft `AdSignifier` — Twitch marks ad pods with this in `#EXT-X-DATERANGE`. + static const adSignifier = 'stitched'; + + /// vaft `ForceAccessTokenPlayerType` — used for the primary stream when + /// ad blocking is enabled. + static const forceAccessTokenPlayerType = 'popout'; + + /// vaft `BackupPlayerTypes` order when the primary playlist still has ads. + static const backupPlayerTypes = [ + 'embed', + 'popout', + 'autoplay', + ]; + + /// vaft `FallbackPlayerType` when every backup still carries ads. + static const fallbackPlayerType = 'embed'; + + /// GQL `platform` for a given player type (vaft uses `android` for autoplay). + static String platformForPlayerType(String playerType) => + playerType == 'autoplay' ? 'android' : 'web'; + + static bool playlistContainsAds(String playlistText) => + playlistText.contains(adSignifier); + + /// True when [text] looks like a midroll (vaft midroll banner path). + static bool playlistIsMidroll(String playlistText) => + playlistText.contains('"MIDROLL"') || + playlistText.contains('"midroll"') || + playlistText.contains("MIDROLL"); +} + +/// Resolves Usher HLS URLs using TwitchAdSolutions player-type strategy. +class TwitchAdFreePlayback { + TwitchAdFreePlayback(this._gql); + + final TwitchGqlApi _gql; + + /// Primary URL when adblock is on: force [TwitchAdSolutions.forceAccessTokenPlayerType]. + Future<({String url, PlaybackAccessToken token, String playerType})> + resolvePrimary({ + required String login, + String? authToken, + }) { + return _resolveForPlayerType( + login: login, + authToken: authToken, + playerType: TwitchAdSolutions.forceAccessTokenPlayerType, + requireAdFree: false, + ); + } + + /// Walks [TwitchAdSolutions.backupPlayerTypes] for a playlist without ads. + /// Falls back to [TwitchAdSolutions.fallbackPlayerType] even if ads remain. + Future<({String url, PlaybackAccessToken token, String playerType})> + resolveBackup({ + required String login, + String? authToken, + }) async { + for (final playerType in TwitchAdSolutions.backupPlayerTypes) { + try { + final resolved = await _resolveForPlayerType( + login: login, + authToken: authToken, + playerType: playerType, + requireAdFree: true, + ); + debugPrint( + 'TwitchAdFreePlayback: backup OK playerType=$playerType', + ); + return resolved; + } catch (e) { + debugPrint( + 'TwitchAdFreePlayback: backup playerType=$playerType failed: $e', + ); + } + } + + debugPrint( + 'TwitchAdFreePlayback: all backups had ads; using fallback ' + '${TwitchAdSolutions.fallbackPlayerType}', + ); + return _resolveForPlayerType( + login: login, + authToken: authToken, + playerType: TwitchAdSolutions.fallbackPlayerType, + requireAdFree: false, + ); + } + + Future<({String url, PlaybackAccessToken token, String playerType})> + _resolveForPlayerType({ + required String login, + String? authToken, + required String playerType, + required bool requireAdFree, + }) async { + final token = await _gql.getPlaybackAccessToken( + login: login, + authToken: authToken, + playerType: playerType, + platform: TwitchAdSolutions.platformForPlayerType(playerType), + ); + final url = _gql.buildHlsUrl(login: login, token: token); + + if (requireAdFree) { + final hasAds = await _multivariantHasAds(url); + if (hasAds) { + throw StateError('Playlist for playerType=$playerType still has ads'); + } + } + + return (url: url, token: token, playerType: playerType); + } + + /// Fetches the multivariant playlist and samples the first media variant for + /// the ad signifier (same idea as vaft probing backup encodings). + Future _multivariantHasAds(String hlsUrl) async { + final master = await _gql.fetchPlaylistText(hlsUrl); + final mediaUrl = firstMediaPlaylistUrl(master, baseUrl: hlsUrl); + if (mediaUrl == null) { + // Can't verify — treat as ad-free so we don't reject a usable stream. + return false; + } + final media = await _gql.fetchPlaylistText(mediaUrl); + return TwitchAdSolutions.playlistContainsAds(media); + } + + /// First `.m3u8` URI in a multivariant playlist, resolved against [baseUrl]. + @visibleForTesting + static String? firstMediaPlaylistUrl( + String masterPlaylist, { + required String baseUrl, + }) { + final lines = masterPlaylist.replaceAll('\r', '').split('\n'); + for (final raw in lines) { + final line = raw.trim(); + if (line.isEmpty || line.startsWith('#')) continue; + if (!line.contains('.m3u8')) continue; + return Uri.parse(baseUrl).resolve(line).toString(); + } + return null; + } +} + +/// Tiny blank MP4 (same payload vaft serves for stripped ad segments). Exposed +/// for tests / future local playlist rewriting; not required for URL-swap mode. +const twitchAdSolutionsBlankSegmentDataUri = + 'data:video/mp4;base64,AAAAKGZ0eXBtcDQyAAAAAWlzb21tcDQyZGFzaGF2YzFpc282aGxzZgAABEltb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAYagAAAAAAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAABqHRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAURtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAALuAAAAAAFXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAADvbWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAACzc3RibAAAAGdzdHNkAAAAAAAAAAEAAABXbXA0YQAAAAAAAAABAAAAAAAAAAAAAgAQAAAAALuAAAAAAAAzZXNkcwAAAAADgICAIgABAASAgIAUQBUAAAAAAAAAAAAAAAWAgIACEZAGgICAAQIAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAeV0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAGBbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAA9CQAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABLG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAOxzdGJsAAAAoHN0c2QAAAAAAAAAAQAAAJBhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBTUAe/+EAI2dNQB6WUoFAX/LgLUBAQFAAAD6AAA6mDgAAHoQAA9CW7y4KAQAEaOuPIAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAASG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAC4AAAAAAoAAAAAAACB0cmV4AAAAAAAAAAIAAAABAACCNQAAAAACQAAA'; diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index 9d6a5605..f72e7250 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -34,12 +34,18 @@ class TwitchGqlApi extends BaseApiClient { /// When [authToken] is provided, the request is authenticated which may /// grant ad-free playback for subscribers and Turbo users. /// + /// [playerType] / [platform] mirror the web player's GQL variables. Ad + /// blocking (TwitchAdSolutions / vaft) uses non-`site` player types such as + /// `popout` / `embed` / `autoplay`. + /// /// Uses the web client's persisted GQL query (smaller payload, blends in /// with regular web traffic); falls back to the inline query template if /// Twitch ever rotates the persisted-query hash. Future getPlaybackAccessToken({ required String login, String? authToken, + String playerType = 'site', + String platform = 'web', }) async { final headers = { 'Client-ID': twitchGqlClientId, @@ -52,8 +58,8 @@ class TwitchGqlApi extends BaseApiClient { 'login': login, 'isVod': false, 'vodID': '', - 'playerType': 'site', - 'platform': 'web', + 'playerType': playerType, + 'platform': platform, }; Future query(Map body) => @@ -92,6 +98,11 @@ class TwitchGqlApi extends BaseApiClient { return PlaybackAccessToken.fromGqlResponse(response); } + /// Fetches raw playlist text (multivariant or media) with playback headers. + Future fetchPlaylistText(String url) async { + return get(url, headers: playbackHttpHeaders); + } + /// Builds the HLS stream URL for the given channel [login] and [token]. String buildHlsUrl({ required String login, diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index faeb5c59..28911fd4 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -6,6 +6,7 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_ad_solutions.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/constants.dart'; @@ -113,9 +114,23 @@ abstract class NativeVideoStoreBase /// suspended rather than burning refresh attempts on ad-induced noise. var _isAdBreakActive = false; + /// True after we swapped to a TwitchAdSolutions backup player-type URL. + var _usingAdBackup = false; + + /// Serializes ad-break URL swaps so overlapping adBreakStream events don't + /// race loadUrl calls. + var _adSwapInFlight = false; + + late final TwitchAdFreePlayback _adFreePlayback = + TwitchAdFreePlayback(twitchGqlApi); + @readonly var _loading = true; + /// Whether ad blocking is actively avoiding/replacing an ad pod. + @readonly + var _blockingAds = false; + @readonly var _paused = true; @@ -276,6 +291,12 @@ abstract class NativeVideoStoreBase debugPrint( 'NativeVideoStore: ad break ${isActive ? 'started' : 'ended'}', ); + if (!settingsStore.blockTwitchAds) { + runInAction(() => _blockingAds = false); + return; + } + // Fire-and-forget: swap stream URL per TwitchAdSolutions backup path. + unawaited(_onAdBreakChanged(isActive)); }); _pipSub = _controller!.isPipEnabledStream.listen((isPip) { @@ -378,12 +399,27 @@ abstract class NativeVideoStoreBase Future _loadStream(int generation) async { // Use web cookie token (works with web Client-ID) for ad-free playback. final authToken = authStore.gqlToken; + late final String hlsUrl; late final PlaybackAccessToken token; try { - token = await twitchGqlApi.getPlaybackAccessToken( - login: userLogin, - authToken: authToken, - ); + if (settingsStore.blockTwitchAds) { + final resolved = await _adFreePlayback.resolvePrimary( + login: userLogin, + authToken: authToken, + ); + hlsUrl = resolved.url; + token = resolved.token; + debugPrint( + 'NativeVideoStore: adblock primary ' + 'playerType=${resolved.playerType}', + ); + } else { + token = await twitchGqlApi.getPlaybackAccessToken( + login: userLogin, + authToken: authToken, + ); + hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token); + } } catch (e) { if (authToken != null) { debugPrint('NativeVideoStore: auth token failed, retrying without: $e'); @@ -392,7 +428,16 @@ abstract class NativeVideoStoreBase // reflects the true state and future loads skip this request. authStore.invalidateGqlToken(); } - token = await twitchGqlApi.getPlaybackAccessToken(login: userLogin); + if (settingsStore.blockTwitchAds) { + final resolved = await _adFreePlayback.resolvePrimary( + login: userLogin, + ); + hlsUrl = resolved.url; + token = resolved.token; + } else { + token = await twitchGqlApi.getPlaybackAccessToken(login: userLogin); + hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token); + } } else { rethrow; } @@ -401,10 +446,11 @@ abstract class NativeVideoStoreBase if (_disposed || generation != _initGeneration || !settingsStore.showVideo) { return; } - runInAction( - () => _restrictedStreamQualities = token.restrictedQualities, - ); - final hlsUrl = twitchGqlApi.buildHlsUrl(login: userLogin, token: token); + runInAction(() { + _restrictedStreamQualities = token.restrictedQualities; + _usingAdBackup = false; + _blockingAds = false; + }); await _controller!.loadUrl( url: hlsUrl, @@ -417,6 +463,66 @@ abstract class NativeVideoStoreBase _startLatencyPolling(); } + /// TwitchAdSolutions backup swap when the native player reports a stitched + /// ad break; restores the primary (popout) URL when the break ends. + Future _onAdBreakChanged(bool isActive) async { + if (_disposed || !settingsStore.blockTwitchAds || _controller == null) { + return; + } + if (_adSwapInFlight) return; + _adSwapInFlight = true; + final generation = _initGeneration; + try { + if (isActive) { + runInAction(() => _blockingAds = true); + final resolved = await _adFreePlayback.resolveBackup( + login: userLogin, + authToken: authStore.gqlToken, + ); + if (_disposed || generation != _initGeneration) return; + debugPrint( + 'NativeVideoStore: blocking ads via ' + 'playerType=${resolved.playerType}', + ); + await _controller!.loadUrl( + url: resolved.url, + headers: TwitchGqlApi.playbackHttpHeaders, + ); + if (_disposed || generation != _initGeneration) return; + await _controller!.configureForLivePlayback(); + runInAction(() { + _usingAdBackup = true; + _restrictedStreamQualities = resolved.token.restrictedQualities; + }); + } else if (_usingAdBackup) { + debugPrint('NativeVideoStore: ad break ended, restoring primary'); + final resolved = await _adFreePlayback.resolvePrimary( + login: userLogin, + authToken: authStore.gqlToken, + ); + if (_disposed || generation != _initGeneration) return; + await _controller!.loadUrl( + url: resolved.url, + headers: TwitchGqlApi.playbackHttpHeaders, + ); + if (_disposed || generation != _initGeneration) return; + await _controller!.configureForLivePlayback(); + runInAction(() { + _usingAdBackup = false; + _blockingAds = false; + _restrictedStreamQualities = resolved.token.restrictedQualities; + }); + } else { + runInAction(() => _blockingAds = false); + } + } catch (e) { + debugPrint('NativeVideoStore: adblock swap failed: $e'); + runInAction(() => _blockingAds = _isAdBreakActive || _usingAdBackup); + } finally { + _adSwapInFlight = false; + } + } + /// Shared error handler for both `_initPlayer` and `_reloadCurrentController`. /// Checks stream liveness, auto-retries if CDN is still serving stale /// manifests, or surfaces the error with the overlay visible. @@ -1278,6 +1384,9 @@ abstract class NativeVideoStoreBase _paused = true; _isStalled = false; _isAdBreakActive = false; + _usingAdBackup = false; + _blockingAds = false; + _adSwapInFlight = false; _restrictedStreamQualities = []; _isQualitySwitching = false; _isAudioOnlyMode = false; diff --git a/lib/screens/channel/video/native_video_store.g.dart b/lib/screens/channel/video/native_video_store.g.dart index 8f1167ce..71fadd4f 100644 --- a/lib/screens/channel/video/native_video_store.g.dart +++ b/lib/screens/channel/video/native_video_store.g.dart @@ -57,6 +57,29 @@ mixin _$NativeVideoStore on NativeVideoStoreBase, Store { }); } + late final _$_blockingAdsAtom = Atom( + name: 'NativeVideoStoreBase._blockingAds', + context: context, + ); + + bool get blockingAds { + _$_blockingAdsAtom.reportRead(); + return super._blockingAds; + } + + @override + bool get isBlockingAds => blockingAds; + + @override + bool get _blockingAds => blockingAds; + + @override + set _blockingAds(bool value) { + _$_blockingAdsAtom.reportWrite(value, super._blockingAds, () { + super._blockingAds = value; + }); + } + late final _$_pausedAtom = Atom( name: 'NativeVideoStoreBase._paused', context: context, diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index eceb6c08..f3589112 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -322,6 +322,25 @@ class VideoOverlay extends StatelessWidget { height: 80, // Covers bottom area around controls child: Container(decoration: bottomGradient), ), + if (videoStore.isBlockingAds) + Positioned( + top: 8, + left: 8, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.8), + ), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 5), + child: Text( + 'Blocking ads', + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ), + ), + ), // Content Stack( children: [ diff --git a/lib/screens/channel/video/video_player_interface.dart b/lib/screens/channel/video/video_player_interface.dart index 9838e7ad..e8dd75d0 100644 --- a/lib/screens/channel/video/video_player_interface.dart +++ b/lib/screens/channel/video/video_player_interface.dart @@ -29,6 +29,9 @@ abstract class VideoPlayerInterface { String get streamQuality; String? get latency; + /// True while TwitchAdSolutions is actively avoiding an ad break. + bool get isBlockingAds; + void handleVideoTap(); void handlePausePlay(); void handleToggleOverlay(); diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index cdf22744..971a25bf 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -183,9 +183,20 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { // Every real navigation invalidates the injected JS context, so // every one must re-arm init. Unconditional is safe: the // onPageFinished handler below still filters on videoUrl. - onPageStarted: (url) => _needsInit = true, + onPageStarted: (url) { + _needsInit = true; + // Best-effort: inject vaft as early as the WebView allows. + // True document-start injection isn't exposed by webview_flutter + // on Android; native player remains the reliable adblock path. + if (settingsStore.blockTwitchAds) { + unawaited(_injectVaftUserScript()); + } + }, onPageFinished: (url) async { if (url != videoUrl) return; + if (settingsStore.blockTwitchAds) { + await _injectVaftUserScript(); + } if (!_needsInit) return; _needsInit = false; await initVideo(); @@ -263,6 +274,11 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { @readonly var _isInPipMode = false; + /// WebView path relies on the injected vaft userscript's own overlay; the + /// Dart overlay banner is driven by the native player only. + @override + bool get isBlockingAds => false; + /// The video URL to use for the webview. String get videoUrl => 'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty'; @@ -636,6 +652,22 @@ abstract class VideoStoreBase with Store implements VideoPlayerInterface { } } + /// Injects [assets/js/vaft.user.js] (TwitchAdSolutions) into the player page. + Future _injectVaftUserScript() async { + try { + final raw = await rootBundle.loadString('assets/js/vaft.user.js'); + // Strip UserScript metadata; keep the IIFE body. + final script = raw.replaceFirst( + RegExp(r'^[\s\S]*?==/UserScript==\s*', multiLine: true), + '', + ); + await videoWebViewController.runJavaScript(script); + debugPrint('VideoStore: injected TwitchAdSolutions vaft'); + } catch (e) { + debugPrint('VideoStore: vaft inject failed: $e'); + } + } + /// Called whenever the video/overlay is tapped. @override @action diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 9330ad83..30599f99 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -69,6 +69,7 @@ abstract class _SettingsStoreBase with Store { static const defaultDefaultToHighestQuality = false; static const defaultUseTextureRendering = true; static const defaultUseNativePlayer = true; + static const defaultBlockTwitchAds = true; static const defaultShowOverlay = true; static const defaultToggleableOverlay = false; @@ -91,6 +92,12 @@ abstract class _SettingsStoreBase with Store { @observable var useNativePlayer = defaultUseNativePlayer; + /// TwitchAdSolutions (vaft)-style ad blocking for the native player, and + /// userscript injection for the legacy WebView player. + @JsonKey(defaultValue: defaultBlockTwitchAds) + @observable + var blockTwitchAds = defaultBlockTwitchAds; + /// 5.2.0 made native the default. 5.1.0 already wrote /// `useNativePlayer: false` into saved settings, so a Dart default /// change alone would leave existing installs on WebView. @@ -123,6 +130,7 @@ abstract class _SettingsStoreBase with Store { defaultToHighestQuality = defaultDefaultToHighestQuality; useTextureRendering = defaultUseTextureRendering; useNativePlayer = defaultUseNativePlayer; + blockTwitchAds = defaultBlockTwitchAds; showOverlay = defaultShowOverlay; toggleableOverlay = defaultToggleableOverlay; diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 4be11e2e..a583ac94 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -24,6 +24,7 @@ SettingsStore _$SettingsStoreFromJson( ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..useTextureRendering = json['useTextureRendering'] as bool? ?? true ..useNativePlayer = json['useNativePlayer'] as bool? ?? true + ..blockTwitchAds = json['blockTwitchAds'] as bool? ?? true ..nativePlayerV52Migrated = json['nativePlayerV52Migrated'] as bool? ?? false ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false @@ -107,6 +108,7 @@ Map _$SettingsStoreToJson( 'defaultToHighestQuality': instance.defaultToHighestQuality, 'useTextureRendering': instance.useTextureRendering, 'useNativePlayer': instance.useNativePlayer, + 'blockTwitchAds': instance.blockTwitchAds, 'nativePlayerV52Migrated': instance.nativePlayerV52Migrated, 'showOverlay': instance.showOverlay, 'toggleableOverlay': instance.toggleableOverlay, @@ -352,6 +354,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$blockTwitchAdsAtom = Atom( + name: '_SettingsStoreBase.blockTwitchAds', + context: context, + ); + + @override + bool get blockTwitchAds { + _$blockTwitchAdsAtom.reportRead(); + return super.blockTwitchAds; + } + + @override + set blockTwitchAds(bool value) { + _$blockTwitchAdsAtom.reportWrite(value, super.blockTwitchAds, () { + super.blockTwitchAds = value; + }); + } + late final _$showOverlayAtom = Atom( name: '_SettingsStoreBase.showOverlay', context: context, @@ -1245,6 +1265,7 @@ showVideo: ${showVideo}, defaultToHighestQuality: ${defaultToHighestQuality}, useTextureRendering: ${useTextureRendering}, useNativePlayer: ${useNativePlayer}, +blockTwitchAds: ${blockTwitchAds}, showOverlay: ${showOverlay}, toggleableOverlay: ${toggleableOverlay}, showLatency: ${showLatency}, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index 3eb39ac8..6e3ba50e 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -34,6 +34,21 @@ class VideoSettings extends StatelessWidget { onChanged: (newValue) => settingsStore.useNativePlayer = newValue, ), + if (settingsStore.showVideo) + SettingsListSwitch( + title: 'Block Twitch ads', + subtitle: Text( + settingsStore.useNativePlayer + ? 'Uses TwitchAdSolutions (vaft): popout/embed/autoplay player types and backup streams during ad breaks.' + : 'Injects the vaft userscript into the WebView. Native player is more reliable — enable it above if ads still play.', + ), + value: settingsStore.blockTwitchAds, + onChanged: (newValue) { + settingsStore.blockTwitchAds = newValue; + // Ad blocking is most effective on the native path. + if (newValue) settingsStore.useNativePlayer = true; + }, + ), if (!Platform.isIOS || isIPad()) SettingsListSwitch( title: 'Default to highest quality', diff --git a/pubspec.yaml b/pubspec.yaml index 3466cc1c..9a54d21d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -151,6 +151,7 @@ flutter: - assets/icons/logo.svg - assets/fonts/ - assets/release-notes.md + - assets/js/vaft.user.js # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/apis/twitch_ad_solutions_test.dart b/test/apis/twitch_ad_solutions_test.dart new file mode 100644 index 00000000..44103bc6 --- /dev/null +++ b/test/apis/twitch_ad_solutions_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/twitch_ad_solutions.dart'; + +void main() { + group('TwitchAdSolutions', () { + test('playlistContainsAds detects stitched signifier', () { + expect( + TwitchAdSolutions.playlistContainsAds( + '#EXTM3U\n#EXT-X-DATERANGE:ID="stitched-ad-1"\n', + ), + isTrue, + ); + expect( + TwitchAdSolutions.playlistContainsAds('#EXTM3U\n#EXTINF:2.0,\nlive.ts\n'), + isFalse, + ); + }); + + test('playlistIsMidroll detects midroll markers', () { + expect( + TwitchAdSolutions.playlistIsMidroll('X-TV-TWITCH-AD-POD-TYPE="MIDROLL"'), + isTrue, + ); + expect(TwitchAdSolutions.playlistIsMidroll('live only'), isFalse); + }); + + test('platformForPlayerType matches vaft', () { + expect(TwitchAdSolutions.platformForPlayerType('autoplay'), 'android'); + expect(TwitchAdSolutions.platformForPlayerType('popout'), 'web'); + expect(TwitchAdSolutions.platformForPlayerType('embed'), 'web'); + }); + + test('force / backup player types match vaft defaults', () { + expect(TwitchAdSolutions.forceAccessTokenPlayerType, 'popout'); + expect(TwitchAdSolutions.backupPlayerTypes, [ + 'embed', + 'popout', + 'autoplay', + ]); + expect(TwitchAdSolutions.fallbackPlayerType, 'embed'); + }); + }); + + group('TwitchAdFreePlayback.firstMediaPlaylistUrl', () { + test('resolves relative media playlist against base', () { + const master = ''' +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=1000,RESOLUTION=640x360 +360p.m3u8 +'''; + final url = TwitchAdFreePlayback.firstMediaPlaylistUrl( + master, + baseUrl: 'https://usher.ttvnw.net/api/v2/channel/hls/foo.m3u8?x=1', + ); + expect( + url, + 'https://usher.ttvnw.net/api/v2/channel/hls/360p.m3u8', + ); + }); + + test('keeps absolute media playlist URLs', () { + const master = ''' +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=1000 +https://video-weaver.example/v1/playlist/abc.m3u8 +'''; + final url = TwitchAdFreePlayback.firstMediaPlaylistUrl( + master, + baseUrl: 'https://usher.ttvnw.net/foo.m3u8', + ); + expect(url, 'https://video-weaver.example/v1/playlist/abc.m3u8'); + }); + + test('returns null when no media URI present', () { + expect( + TwitchAdFreePlayback.firstMediaPlaylistUrl( + '#EXTM3U\n', + baseUrl: 'https://example.com/x.m3u8', + ), + isNull, + ); + }); + }); +} diff --git a/test/screens/settings/stores/settings_store_test.dart b/test/screens/settings/stores/settings_store_test.dart index 12e5c5b9..f6e6612c 100644 --- a/test/screens/settings/stores/settings_store_test.dart +++ b/test/screens/settings/stores/settings_store_test.dart @@ -15,6 +15,7 @@ void main() { expect(store.defaultToHighestQuality, isFalse); expect(store.useTextureRendering, isTrue); expect(store.useNativePlayer, isTrue); + expect(store.blockTwitchAds, isTrue); expect(store.showOverlay, isTrue); expect(store.toggleableOverlay, isFalse); expect(store.showLatency, isFalse);