diff --git a/README.md b/README.md index 1ee07bd5..d59bf2c7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Icon + Icon
TTV LOL PRO
@@ -39,8 +39,9 @@ This fork: - disables TTV LOL for channels you are subscribed to, - lets you whitelist channels, - improves TTV LOL's popup by showing stream status and "Whitelist" button, -- lets you add custom primary/fallback proxies, -- falls back to the stream with ads if the API server errors out. +- falls back to the stream with ads if the API server errors out, +- improves your privacy by removing your Twitch token from API requests, +- lets you add custom primary/fallback proxies. **Recommendations:** @@ -49,9 +50,14 @@ This fork: - remove banner ads, - block ads on VODs. -## Screenshots +## Screenshot -![Popup](https://i.imgur.com/VucfuL6.png) +
+ Popup on Firefox +
## Installation diff --git a/package-lock.json b/package-lock.json index 1468918e..1e3c4372 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ttv-lol-pro", - "version": "1.5.1", + "version": "1.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ttv-lol-pro", - "version": "1.5.1", + "version": "1.6.0", "license": "GPL-3.0", "dependencies": { "semver-compare": "^1.0.0" @@ -15,6 +15,7 @@ "@parcel/config-webextension": "^2.8.2", "@types/semver-compare": "^1.0.1", "@types/webextension-polyfill": "^0.9.2", + "amazon-ivs-player": "^1.14.0", "parcel": "^2.8.2", "postcss": "^8.4.20", "prettier": "^2.8.1", @@ -121,6 +122,18 @@ "node": ">=4" } }, + "node_modules/@babel/runtime": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", + "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", + "dev": true, + "dependencies": { + "regenerator-runtime": "^0.13.11" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", @@ -1548,6 +1561,21 @@ "node": ">=0.4.0" } }, + "node_modules/amazon-ivs-player": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/amazon-ivs-player/-/amazon-ivs-player-1.14.0.tgz", + "integrity": "sha512-iZBMOfxHTtDK8C8JHK73uhEk490QLvL946B9S3OuD2xLcuq5SnC1MK7bxY34psI7HNXfGRK4qPXiliHSbRoQRQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.9.2", + "bowser": "^2.10.0", + "promise-polyfill": "^8.1.3", + "typescript": "^4.8.2" + }, + "peerDependencies": { + "bowser": "^2.10.0" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1578,6 +1606,12 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true }, + "node_modules/bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "dev": true + }, "node_modules/browserslist": { "version": "4.21.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz", @@ -2645,6 +2679,12 @@ } } }, + "node_modules/promise-polyfill": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.2.3.tgz", + "integrity": "sha512-Og0+jCRQetV84U8wVjMNccfGCnMQ9mGs9Hv78QFe+pSDD3gWTpz0y+1QCuxy5d/vBFuZ3iwP2eycAkvqIMPmWg==", + "dev": true + }, "node_modules/react-error-overlay": { "version": "6.0.9", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz", @@ -3004,6 +3044,15 @@ } } }, + "@babel/runtime": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", + "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, "@jridgewell/gen-mapping": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", @@ -3916,6 +3965,18 @@ "integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==", "dev": true }, + "amazon-ivs-player": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/amazon-ivs-player/-/amazon-ivs-player-1.14.0.tgz", + "integrity": "sha512-iZBMOfxHTtDK8C8JHK73uhEk490QLvL946B9S3OuD2xLcuq5SnC1MK7bxY34psI7HNXfGRK4qPXiliHSbRoQRQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.9.2", + "bowser": "^2.10.0", + "promise-polyfill": "^8.1.3", + "typescript": "^4.8.2" + } + }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3940,6 +4001,12 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true }, + "bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "dev": true + }, "browserslist": { "version": "4.21.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz", @@ -4591,6 +4658,12 @@ "integrity": "sha512-bty7C2Ecard5EOXirtzeCAqj4FU4epeuWrQt/Z+sh8UVEpBlBZ3m3KNPz2kFu7KgRTQx/C9o4/TdquPD1jOqjQ==", "dev": true }, + "promise-polyfill": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.2.3.tgz", + "integrity": "sha512-Og0+jCRQetV84U8wVjMNccfGCnMQ9mGs9Hv78QFe+pSDD3gWTpz0y+1QCuxy5d/vBFuZ3iwP2eycAkvqIMPmWg==", + "dev": true + }, "react-error-overlay": { "version": "6.0.9", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz", diff --git a/package.json b/package.json index 18c14a50..43ce53ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ttv-lol-pro", - "version": "1.5.1", + "version": "1.6.0", "description": "TTV LOL PRO removes livestream ads from Twitch", "@parcel/bundler-default": { "minBundles": 10000000, @@ -37,6 +37,7 @@ "@parcel/config-webextension": "^2.8.2", "@types/semver-compare": "^1.0.1", "@types/webextension-polyfill": "^0.9.2", + "amazon-ivs-player": "^1.14.0", "parcel": "^2.8.2", "postcss": "^8.4.20", "prettier": "^2.8.1", diff --git a/src/assets/icon.png b/src/assets/icon.png deleted file mode 100644 index 08d1644f..00000000 Binary files a/src/assets/icon.png and /dev/null differ diff --git a/src/background/background.ts b/src/background/background.ts index 81da6a02..1e06980a 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -1,16 +1,17 @@ import browser from "webextension-polyfill"; import isChrome from "../common/ts/isChrome"; -import onBeforeRequest from "./handlers/onBeforeRequest"; -import onBeforeSendHeaders from "./handlers/onBeforeSendHeaders"; -import onHeadersReceived from "./handlers/onHeadersReceived"; -import onStartup from "./handlers/onStartup"; +import onApiHeadersReceived from "./handlers/onApiHeadersReceived"; +import onBeforeManifestRequest from "./handlers/onBeforeManifestRequest"; +import onBeforeSendApiHeaders from "./handlers/onBeforeSendApiHeaders"; +import onBeforeVideoWeaverRequest from "./handlers/onBeforeVideoWeaverRequest"; +import onStartupUpdateCheck from "./handlers/onStartupUpdateCheck"; // Check for updates on Chrome startup. -if (isChrome) browser.runtime.onStartup.addListener(onStartup); +if (isChrome) browser.runtime.onStartup.addListener(onStartupUpdateCheck); // Redirect the HLS master manifest request to TTV LOL's API. browser.webRequest.onBeforeRequest.addListener( - onBeforeRequest, + onBeforeManifestRequest, { urls: [ "https://usher.ttvnw.net/api/channel/hls/*", @@ -20,16 +21,25 @@ browser.webRequest.onBeforeRequest.addListener( ["blocking"] ); +// Detect midrolls by looking for an ad signifier in the video weaver response. +browser.webRequest.onBeforeRequest.addListener( + onBeforeVideoWeaverRequest, + { + urls: ["https://*.ttvnw.net/*"], // Immediately filtered to video-weaver URLs in handler. + }, + ["blocking"] +); + // Add the `X-Donate-To` header to API requests. browser.webRequest.onBeforeSendHeaders.addListener( - onBeforeSendHeaders, + onBeforeSendApiHeaders, { urls: ["https://api.ttv.lol/playlist/*", "https://api.ttv.lol/vod/*"] }, ["blocking", "requestHeaders"] ); // Monitor API error responses. browser.webRequest.onHeadersReceived.addListener( - onHeadersReceived, + onApiHeadersReceived, { urls: ["https://api.ttv.lol/playlist/*", "https://api.ttv.lol/vod/*"] }, ["blocking"] ); diff --git a/src/background/handlers/onApiHeadersReceived.ts b/src/background/handlers/onApiHeadersReceived.ts new file mode 100644 index 00000000..1563076e --- /dev/null +++ b/src/background/handlers/onApiHeadersReceived.ts @@ -0,0 +1,61 @@ +import { WebRequest } from "webextension-polyfill"; +import { TTV_LOL_API_URL_REGEX } from "../../common/ts/regexes"; +import store from "../../store"; +import type { StreamStatus } from "../../types"; + +export default function onApiHeadersReceived( + details: WebRequest.OnHeadersReceivedDetailsType +): WebRequest.BlockingResponseOrPromise { + const streamId = getStreamIdFromUrl(details.url); + if (!streamId) return {}; + + const isServerError = 500 <= details.statusCode && details.statusCode < 600; + if (isServerError) { + // Add error to stream status. + const status = getStreamStatusFromStreamId(streamId); + const errors = status.errors || []; + errors.push({ + timestamp: Date.now(), + status: details.statusCode, + }); + + store.state.streamStatuses[streamId] = { + ...status, + errors: errors, + proxyCountry: undefined, // Reset proxy country on error. + }; + console.log(`${streamId}: ${status.errors.length + 1} errors`); + console.log(`${streamId}: Redirect canceled (Error ${details.statusCode})`); + + return { + cancel: true, // This forces Twitch to retry the request (up to 2 times). + }; + } else { + // Clear errors if server is not returning 5xx. + const status = getStreamStatusFromStreamId(streamId); + store.state.streamStatuses[streamId] = { + ...status, + errors: [], + }; + + return {}; + } +} + +function getStreamIdFromUrl(url: string): string | undefined { + const match = TTV_LOL_API_URL_REGEX.exec(url); + if (!match) return; + const [, streamId] = match; + return streamId; +} + +function getStreamStatusFromStreamId(streamId: string): StreamStatus { + const status = store.state.streamStatuses[streamId]; + const defaultStatus = { + redirected: true, + reason: "", + errors: [], + } as StreamStatus; + + return status || defaultStatus; +} diff --git a/src/background/handlers/onBeforeRequest.ts b/src/background/handlers/onBeforeManifestRequest.ts similarity index 98% rename from src/background/handlers/onBeforeRequest.ts rename to src/background/handlers/onBeforeManifestRequest.ts index 72df085d..23a084d5 100644 --- a/src/background/handlers/onBeforeRequest.ts +++ b/src/background/handlers/onBeforeManifestRequest.ts @@ -4,9 +4,9 @@ import { TWITCH_API_URL_REGEX } from "../../common/ts/regexes"; import store from "../../store"; import { PlaylistType, Token } from "../../types"; -export default function onBeforeRequest( +export default function onBeforeManifestRequest( details: WebRequest.OnBeforeRequestDetailsType -): WebRequest.BlockingResponse | Promise { +): WebRequest.BlockingResponseOrPromise { const match = TWITCH_API_URL_REGEX.exec(details.url); if (!match) return {}; const [, _type, streamId, _params] = match; diff --git a/src/background/handlers/onBeforeSendApiHeaders.ts b/src/background/handlers/onBeforeSendApiHeaders.ts new file mode 100644 index 00000000..775ea2b4 --- /dev/null +++ b/src/background/handlers/onBeforeSendApiHeaders.ts @@ -0,0 +1,59 @@ +import { WebRequest } from "webextension-polyfill"; +import filterResponseDataWrapper from "../../common/ts/filterResponseDataWrapper"; +import { TTV_LOL_API_URL_REGEX } from "../../common/ts/regexes"; +import store from "../../store"; + +const PROXY_COUNTRY_REGEX = /USER-COUNTRY="([A-Z]+)"/i; + +export default function onBeforeSendApiHeaders( + details: WebRequest.OnBeforeSendHeadersDetailsType +): WebRequest.BlockingResponseOrPromise { + const requestHeaders = details.requestHeaders || []; + requestHeaders.push({ + name: "X-Donate-To", + value: "https://ttv.lol/donate", + }); + + const response = { + requestHeaders: requestHeaders, + } as WebRequest.BlockingResponse; + + filterResponseDataWrapper(details, text => { + const streamId = getStreamIdFromUrl(details.url); + const proxyCountry = extractProxyCountryFromManifest(text); + if (!streamId || !proxyCountry) return text; + + setStreamStatusProxyCountry(streamId, proxyCountry); + + return text; + }); + + return response; +} + +function getStreamIdFromUrl(url: string): string | undefined { + const match = TTV_LOL_API_URL_REGEX.exec(url); + if (!match) return; + const [, streamId] = match; + return streamId; +} + +function extractProxyCountryFromManifest(text: string): string | undefined { + const match = PROXY_COUNTRY_REGEX.exec(text); + if (!match) return; + const [, proxyCountry] = match; + return proxyCountry; +} + +function setStreamStatusProxyCountry( + streamId: string, + proxyCountry: string +): void { + const status = store.state.streamStatuses[streamId]; + if (!status) return; + + store.state.streamStatuses[streamId] = { + ...status, + proxyCountry: proxyCountry, + }; +} diff --git a/src/background/handlers/onBeforeSendHeaders.ts b/src/background/handlers/onBeforeSendHeaders.ts deleted file mode 100644 index 9c3e5f8b..00000000 --- a/src/background/handlers/onBeforeSendHeaders.ts +++ /dev/null @@ -1,68 +0,0 @@ -import browser, { WebRequest } from "webextension-polyfill"; -import isChrome from "../../common/ts/isChrome"; -import { - MANIFEST_PROXY_COUNTRY_REGEX, - TTV_LOL_API_URL_REGEX, -} from "../../common/ts/regexes"; -import store from "../../store"; - -export default function onBeforeSendHeaders( - details: WebRequest.OnBeforeSendHeadersDetailsType -): WebRequest.BlockingResponse { - if (!details.requestHeaders) { - console.error("`details.requestHeaders` is undefined"); - return {}; - } - details.requestHeaders.push({ - name: "X-Donate-To", - value: "https://ttv.lol/donate", - }); - - const response: WebRequest.BlockingResponse = { - requestHeaders: details.requestHeaders, - }; - - if (isChrome) return response; - - const match = TTV_LOL_API_URL_REGEX.exec(details.url); - if (!match) return response; - const [, streamId] = match; - if (!streamId) return response; - - const filter = browser.webRequest.filterResponseData(details.requestId); - const decoder = new TextDecoder("utf-8"); - - filter.ondata = event => { - const data = decoder.decode(event.data, { stream: true }); - const proxyCountry = extractProxyCountry(data); - if (proxyCountry) { - setStreamStatusProxyCountry(streamId, proxyCountry); - } - filter.write(event.data); - }; - filter.onerror = () => { - console.log(`Error: ${filter.error} for ${details.requestId}`); - }; - filter.onstop = () => filter.disconnect(); - - return response; -} - -function extractProxyCountry(data: string) { - const match = MANIFEST_PROXY_COUNTRY_REGEX.exec(data); - if (!match) return; - const [, proxyCountry] = match; - return proxyCountry; -} - -function setStreamStatusProxyCountry( - streamId: string, - proxyCountry: string | undefined = undefined -) { - if (!proxyCountry) return; - const status = store.state.streamStatuses[streamId]; - store.state.streamStatuses[streamId] = { - ...status, - proxyCountry: proxyCountry, - }; -} diff --git a/src/background/handlers/onBeforeVideoWeaverRequest.ts b/src/background/handlers/onBeforeVideoWeaverRequest.ts new file mode 100644 index 00000000..7f76981e --- /dev/null +++ b/src/background/handlers/onBeforeVideoWeaverRequest.ts @@ -0,0 +1,76 @@ +import browser, { WebRequest } from "webextension-polyfill"; +import filterResponseDataWrapper from "../../common/ts/filterResponseDataWrapper"; +import store from "../../store"; +import type { MidrollMessage } from "../../types"; + +const AD_SIGNIFIER = "stitched"; // From https://github.com/cleanlock/VideoAdBlockForTwitch/blob/145921a822e830da62d39e36e8aafb8ef22c7be6/firefox/content.js#L87 +const START_DATE_REGEX = + /START-DATE="(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+(?:[+-][0-2]\d:[0-5]\d|Z))"/i; // From https://stackoverflow.com/a/3143231 +const VIDEO_WEAVER_URL_REGEX = + /^https?:\/\/video-weaver\.(?:[a-z0-9-]+\.)*ttvnw\.net\//i; +const lastMidrollStartDateString = new Map(); // Tab ID -> Start Date String. + +export default function onBeforeVideoWeaverRequest( + details: WebRequest.OnBeforeRequestDetailsType +): WebRequest.BlockingResponseOrPromise { + if (!VIDEO_WEAVER_URL_REGEX.test(details.url)) return {}; + if (!store.state.resetPlayerOnMidroll) return {}; + + filterResponseDataWrapper(details, replacer); + + return {}; +} + +/** + * Detect midrolls by looking for an ad signifier in the video weaver response. + * @param responseText + * @returns + */ +function replacer( + responseText: string, + details: WebRequest.OnBeforeRequestDetailsType +): string { + // From https://github.com/cleanlock/VideoAdBlockForTwitch/blob/145921a822e830da62d39e36e8aafb8ef22c7be6/firefox/content.js#L523-L527 + const hasAdTags = (text: string) => text.includes(AD_SIGNIFIER); + const isMidroll = (text: string) => + text.includes('"MIDROLL"') || text.includes('"midroll"'); + + const responseTextLines = responseText.split("\n"); + const midrollLine = responseTextLines.find( + line => hasAdTags(line) && isMidroll(line) + ); + if (!midrollLine) return responseText; + + const startDateString = getStartDateStringFromMidrollLine(midrollLine); + if (!startDateString) return responseText; + + // Prevent multiple midroll messages from being sent for the same midroll. + const lastStartDateString = lastMidrollStartDateString.get(details.tabId); + const isSameMidroll = startDateString === lastStartDateString; + if (!isSameMidroll) { + console.log( + `Tab (ID = ${details.tabId}): Detected midroll (Scheduled for ${startDateString})` + ); + + const message = { + type: "midroll", + response: { + startDateString, + }, + } as MidrollMessage; + browser.tabs.sendMessage(details.tabId, message).catch(console.error); + + lastMidrollStartDateString.set(details.tabId, startDateString); + } + + return responseText; +} + +function getStartDateStringFromMidrollLine( + midrollLine: string +): string | undefined { + const startDateMatch = midrollLine.match(START_DATE_REGEX); + if (!startDateMatch) return; + const [, startDateString] = startDateMatch; + return startDateString; +} diff --git a/src/background/handlers/onHeadersReceived.ts b/src/background/handlers/onHeadersReceived.ts deleted file mode 100644 index a491d04e..00000000 --- a/src/background/handlers/onHeadersReceived.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { WebRequest } from "webextension-polyfill"; -import { TTV_LOL_API_URL_REGEX } from "../../common/ts/regexes"; -import store from "../../store"; - -export default function onHeadersReceived( - details: WebRequest.OnHeadersReceivedDetailsType -): WebRequest.BlockingResponse { - const match = TTV_LOL_API_URL_REGEX.exec(details.url); - if (!match) return {}; - const [, streamId] = match; - if (!streamId) return {}; - - const isServerError = 500 <= details.statusCode && details.statusCode < 600; - if (isServerError) { - const status = getStreamStatus(streamId); - store.state.streamStatuses[streamId] = { - ...status, - errors: [ - ...status.errors, - { - timestamp: Date.now(), - status: details.statusCode, - }, - ], - proxyCountry: undefined, - }; - console.log(`${streamId}: ${status.errors.length + 1} errors`); - console.log(`${streamId}: Redirect canceled (Error ${details.statusCode})`); - - return { - cancel: true, - }; - } else { - const status = getStreamStatus(streamId); - store.state.streamStatuses[streamId] = { - ...status, - errors: [], - }; - - return {}; - } -} - -function getStreamStatus(streamId: string) { - return ( - store.state.streamStatuses[streamId] || { - redirected: true, - reason: "", - errors: [], - } - ); -} diff --git a/src/background/handlers/onStartup.ts b/src/background/handlers/onStartupUpdateCheck.ts similarity index 91% rename from src/background/handlers/onStartup.ts rename to src/background/handlers/onStartupUpdateCheck.ts index a0105b3b..5fc4e75c 100644 --- a/src/background/handlers/onStartup.ts +++ b/src/background/handlers/onStartupUpdateCheck.ts @@ -9,9 +9,9 @@ type Update = { }; //#endregion -export default async function onStartup() { +export default async function onStartupUpdateCheck() { if (store.readyState !== "complete") { - store.addEventListener("load", () => onStartup()); + store.addEventListener("load", () => onStartupUpdateCheck()); return; } diff --git a/src/common/ts/filterResponseDataWrapper.ts b/src/common/ts/filterResponseDataWrapper.ts new file mode 100644 index 00000000..27a7c1e0 --- /dev/null +++ b/src/common/ts/filterResponseDataWrapper.ts @@ -0,0 +1,29 @@ +import browser, { WebRequest } from "webextension-polyfill"; + +export default function filterResponseDataWrapper( + details: WebRequest.OnBeforeRequestDetailsType, + replacer: ( + responseText: string, + details: WebRequest.OnBeforeRequestDetailsType + ) => string +): void { + if (!browser.webRequest.filterResponseData) return; + + const filter = browser.webRequest.filterResponseData(details.requestId); + const decoder = new TextDecoder("utf-8"); + const encoder = new TextEncoder(); + + const buffers = [] as ArrayBuffer[]; + filter.ondata = event => buffers.push(event.data); + filter.onstop = () => { + let responseText = ""; + for (const [i, buffer] of buffers.entries()) { + const stream = i !== buffers.length - 1; + responseText += decoder.decode(buffer, { stream }); + } + responseText = replacer(responseText, details); + + filter.write(encoder.encode(responseText)); + filter.close(); + }; +} diff --git a/src/common/ts/log.ts b/src/common/ts/log.ts new file mode 100644 index 00000000..7ca34546 --- /dev/null +++ b/src/common/ts/log.ts @@ -0,0 +1,3 @@ +export default function log(...args: any[]) { + console.log("[TTV LOL PRO]", ...args); +} diff --git a/src/common/ts/regexes.ts b/src/common/ts/regexes.ts index 2dd5a62c..c7ccdbf9 100644 --- a/src/common/ts/regexes.ts +++ b/src/common/ts/regexes.ts @@ -2,4 +2,3 @@ export const TWITCH_URL_REGEX = /^https?:\/\/(?:www\.)?twitch\.tv\/(?:videos\/)?([A-Z0-9][A-Z0-9_]*)/i; export const TWITCH_API_URL_REGEX = /\/(hls|vod)\/(.+)\.m3u8(?:\?(.*))?$/i; export const TTV_LOL_API_URL_REGEX = /\/(?:playlist|vod)\/(.+)\.m3u8/i; -export const MANIFEST_PROXY_COUNTRY_REGEX = /USER-COUNTRY="([A-Z]+)"/i; diff --git a/src/content/content.ts b/src/content/content.ts index 09a3135c..2dd7ece6 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -1,17 +1,82 @@ +import injectedScript from "url:./injected.ts"; +import browser from "webextension-polyfill"; +import $ from "../common/ts/$"; +import log from "../common/ts/log"; import { TWITCH_URL_REGEX } from "../common/ts/regexes"; import store from "../store"; +import type { Message, MidrollMessage } from "../types"; -if (store.readyState === "complete") main(); -else store.addEventListener("load", main); +log("Content script running."); -function main() { +// Clear errors for stream on page load/reload. +if (store.readyState === "complete") clearErrors(); +else store.addEventListener("load", clearErrors); + +// Inject "Reset Player" script into page. +if (document.readyState === "complete") injectScript(); +else document.addEventListener("DOMContentLoaded", injectScript); + +// Listen for messages from background script. +browser.runtime.onMessage.addListener((message: Message, sender) => { + if (sender.id !== browser.runtime.id) return; + + switch (message.type) { + case "midroll": + onMidroll(message as MidrollMessage); + break; + } +}); + +function clearErrors() { const match = TWITCH_URL_REGEX.exec(location.href); if (!match) return; const [, streamId] = match; if (!streamId) return; if (store.state.streamStatuses.hasOwnProperty(streamId)) { - // Clear errors for stream on page load/reload. store.state.streamStatuses[streamId].errors = []; } } + +function injectScript() { + // Only inject script on stream pages. + if (!TWITCH_URL_REGEX.test(location.href)) return; + + // From https://stackoverflow.com/a/9517879 + const script = document.createElement("script"); + script.src = injectedScript; + script.onload = () => script.remove(); + document.head.appendChild(script); +} + +/** + * Reset player if midroll detected. + * @param message + */ +function onMidroll(message: MidrollMessage) { + const { startDateString } = message.response; + + const startDate = new Date(startDateString); + const now = new Date(); + const diff = startDate.getTime() - now.getTime(); + const delay = Math.max(diff, 0); // Prevent negative delay. + + log(`Midroll scheduled for ${startDateString} (in ${delay} ms)`); + + setTimeout(() => { + // Check if FrankerFaceZ's reset player button exists. + const ffzResetPlayerButton = $( + 'button[data-a-target="ffz-player-reset-button"]' + ); + if (ffzResetPlayerButton) { + ffzResetPlayerButton.dispatchEvent( + new MouseEvent("dblclick", { bubbles: true }) + ); + log("Clicked FrankerFaceZ's reset player button."); + } else { + // Otherwise, send message to injected script. + window.postMessage({ type: "resetPlayer" }, "*"); + log("Sent `resetPlayer` message to injected script."); + } + }, delay); +} diff --git a/src/content/injected.ts b/src/content/injected.ts new file mode 100644 index 00000000..34e5c6a1 --- /dev/null +++ b/src/content/injected.ts @@ -0,0 +1,262 @@ +/** + * This code is based on the work of FrankerFaceZ: + * GitHub: https://github.com/FrankerFaceZ/FrankerFaceZ + * Website: https://www.frankerfacez.com/ + */ + +import type { MediaPlayer } from "amazon-ivs-player"; +import log from "../common/ts/log"; + +//#region Types +type DOMContainerElement = Element & { _reactRootContainer?: any }; +type Instances = { + playerInstance: any; + playerSourceInstance: any; +}; +type MediaPlayerInstance = + | MediaPlayer & { + core: any; + requestCaptureAnalytics: any; + startCapture: any; + stopCapture: any; + }; +type SearchOutput = { + cls: Function | null; + instances: Set; + depth: number | null; +}; +type SearchData = { + seen: Set; + classes: (Function | null)[]; + output: SearchOutput[]; + maxDepth: number; +}; +//#endregion + +(function () { + log("Injected into Twitch."); + + const REACT = getReact(); + let accessor: string | null = null; + let instances: Instances | null = null; + + // Listen for messages from the content script. + window.addEventListener("message", event => { + // Only accept messages from this window to itself (i.e. not from any iframes) + if (event.source !== window) return; + if (!event.data) return; + + if (event.data.type === "resetPlayer") { + log("Received `resetPlayer` message."); + if (!instances) instances = getInstances(); + if (!instances.playerInstance || !instances.playerSourceInstance) { + log("Player instance not found."); + return; + } + resetPlayer(instances); + } + }); + + /** + * Get the root element's React instance from the page. + * @param rootSelector + * @returns + */ + function getReact(rootSelector = "body #root") { + const rootElement = document.querySelector( + rootSelector + ) as DOMContainerElement; + const reactRootContainer = rootElement?._reactRootContainer; + const internalRoot = reactRootContainer?._internalRoot; + const react = internalRoot?.current?.child; + return react; + } + + /** + * Find the accessor (key) of the element's React instance. + * @param element + * @returns + */ + function findAccessor(element): string | null { + for (const key in element) { + if (key.startsWith("__reactInternalInstance$")) return key; + } + return null; + } + + /** + * Get the element's React instance. + * @param element + * @returns + */ + function getReactInstance(element) { + if (!accessor) accessor = findAccessor(element); + if (!accessor) return; + + return ( + element[accessor] || + (element._reactRootContainer && + element._reactRootContainer._internalRoot && + element._reactRootContainer._internalRoot.current) || + (element._reactRootContainer && element._reactRootContainer.current) + ); + } + + /** + * Get all React instances matching at least one of the given criterias. + * @param node + * @param criterias + * @param maxDepth + * @param depth + * @param data + * @param traverseRoots + * @returns + */ + function searchAll( + node: any, + criterias: Function[], + maxDepth = 15, + depth = 0, + data: SearchData | null = null, + traverseRoots = true + ): SearchOutput[] { + if (!node) node = REACT; + else if (node._reactInternalFiber) node = node._reactInternalFiber; + else if (node instanceof Node) node = getReactInstance(node); + + // If no search data was provided, create a new object to store search progress. + if (!data) { + data = { + seen: new Set(), + classes: criterias.map(() => null), + output: criterias.map(() => ({ + cls: null, + instances: new Set(), + depth: null, + })), + maxDepth: depth, + }; + } + + // If the node is not valid, or if the search has exceeded the maximum depth, return the search output. + if (!node || node._ffz_no_scan || depth > maxDepth) return data.output; + // Update the maximum depth reached during the search, if necessary. + if (depth > data.maxDepth) data.maxDepth = depth; + + const instance = node.stateNode; + if (instance) { + const cls = instance.constructor; + const idx = data.classes.indexOf(cls); + + if (idx !== -1) { + // If the constructor function has already been seen, add the current React instance to the matching instances + // for the corresponding criteria function. + data.output[idx].instances.add(instance); + } else if (!data.seen.has(cls)) { + // If the constructor function has not yet been seen, check if any of the criteria functions match the current React instance. + let i = criterias.length; + while (i-- > 0) { + if (criterias[i](instance)) { + // Match found. + data.classes[i] = data.output[i].cls = cls; + data.output[i].instances.add(instance); + data.output[i].depth = depth; + break; + } + } + data.seen.add(cls); + } + } + + // Search for matching React instances in the children of the current node. + let child = node.child; + while (child) { + searchAll(child, criterias, maxDepth, depth + 1, data, traverseRoots); + child = child.sibling; + } + + // If the search should traverse root nodes, and the current React instance has a root prop, + // search for matching instances in the root node. + if (traverseRoots && instance && instance.props && instance.props.root) { + const root = instance.props.root._reactRootContainer; + if (root) { + let child = + (root._internalRoot && root._internalRoot.current) || root.current; + while (child) { + searchAll(child, criterias, maxDepth, depth + 1, data, traverseRoots); + child = child.sibling; + } + } + } + + return data.output; + } + + /** + * Get the player and player source instances. + * @returns + */ + function getInstances(): Instances { + const playerCriteria = instance => + instance.setPlayerActive && + instance.props?.playerEvents && + instance.props?.mediaPlayerInstance; + const playerSourceCriteria = instance => + instance.setSrc && instance.setInitialPlaybackSettings; + const criterias = [playerCriteria, playerSourceCriteria]; + + const results = searchAll(REACT, criterias, 1000); + const instances = results + .map(result => Array.from(result.instances.values())) + .flat(); + + return { + playerInstance: instances.find(playerCriteria), + playerSourceInstance: instances.find(playerSourceCriteria), + }; + } + + /** + * Reset the player. + * @param instances + */ + function resetPlayer(instances: Instances) { + const { playerInstance, playerSourceInstance } = instances; + + log("Resetting player."); + + const player = playerInstance.props + .mediaPlayerInstance as MediaPlayerInstance; + + // Are we dealing with a VOD? + const duration = player.getDuration?.() ?? Infinity; + let position = -1; + + if (isFinite(duration) && !isNaN(duration) && duration > 0) { + position = player.getPosition(); + } + + const video = player.core?.mediaSinkManager?.video as HTMLVideoElement; + if (player.attachHTMLVideoElement) { + const newVideo = document.createElement("video"); + const volume = video?.volume ?? player.getVolume(); + const muted = player.isMuted(); + + newVideo.volume = muted ? 0 : volume; + newVideo.playsInline = true; + + video.replaceWith(newVideo); + player.attachHTMLVideoElement(newVideo); + setTimeout(() => { + player.setVolume(volume); + player.setMuted(muted); + }, 0); + } + + playerSourceInstance.setSrc({ isNewMediaPlayerInstance: false }); + + if (position > 0) { + setTimeout(() => player.seekTo(position), 250); + } + } +})(); diff --git a/src/images/brand/icon.png b/src/images/brand/icon.png new file mode 100644 index 00000000..71d9b7a4 Binary files /dev/null and b/src/images/brand/icon.png differ diff --git a/src/assets/logo.png b/src/images/brand/logo.png similarity index 100% rename from src/assets/logo.png rename to src/images/brand/logo.png diff --git a/src/manifest.json b/src/manifest.json index 9379bacd..fc93bb15 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,14 +2,14 @@ "manifest_version": 2, "name": "TTV LOL PRO", "description": "TTV LOL PRO removes livestream ads from Twitch.", - "version": "1.5.1", + "version": "1.6.0", "background": { "persistent": true, "scripts": ["background/background.ts"] }, "browser_action": { "default_icon": { - "128": "assets/icon.png" + "128": "images/brand/icon.png" }, "default_title": "TTV LOL PRO", "default_popup": "popup/menu.html" @@ -21,13 +21,13 @@ }, "content_scripts": [ { - "matches": ["*://*.twitch.tv/*"], + "matches": ["https://www.twitch.tv/*"], "js": ["content/content.ts"], "run_at": "document_start" } ], "icons": { - "128": "assets/icon.png" + "128": "images/brand/icon.png" }, "options_ui": { "browser_style": false, @@ -38,8 +38,8 @@ "storage", "webRequest", "webRequestBlocking", - "https://*.twitch.tv/*", - "https://usher.ttvnw.net/*", - "https://api.ttv.lol/*" + "https://*.ttvnw.net/*", + "https://api.ttv.lol/*", + "https://www.twitch.tv/*" ] } diff --git a/src/options/options.ts b/src/options/options.ts index 241b45ed..64815f10 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -1,9 +1,10 @@ import $ from "../common/ts/$"; +import isChrome from "../common/ts/isChrome"; import readFile from "../common/ts/readFile"; import saveFile from "../common/ts/saveFile"; import store from "../store"; import getDefaultState from "../store/getDefaultState"; -import { KeyOfType } from "../types"; +import type { KeyOfType } from "../types"; //#region Types type AllowedResult = [boolean, string?]; @@ -23,16 +24,26 @@ type ListOptions = { //#endregion //#region HTML Elements +// General +const resetPlayerOnMidrollCheckboxElement = $( + "#reset-player-on-midroll-checkbox" +); +// Whitelisted channels const whitelistedChannelsListElement = $( "#whitelisted-channels-list" ) as HTMLUListElement; -const ignoredChannelSubscriptionsListElement = $( - "#ignored-channel-subscriptions-list" -) as HTMLUListElement; +$; +// Proxies +const serversListElement = $("#servers-list") as HTMLOListElement; +// Privacy const disableVodRedirectCheckboxElement = $( "#disable-vod-redirect-checkbox" ) as HTMLInputElement; -const serversListElement = $("#servers-list") as HTMLOListElement; +// Ignored channel subscriptions +const ignoredChannelSubscriptionsListElement = $( + "#ignored-channel-subscriptions-list" +) as HTMLUListElement; +// Import/Export const exportButtonElement = $("#export-button") as HTMLButtonElement; const importButtonElement = $("#import-button") as HTMLButtonElement; const resetButtonElement = $("#reset-button") as HTMLButtonElement; @@ -66,6 +77,18 @@ function main() { getPromptPlaceholder: () => "Enter a channel nameā€¦", } ); + // Reset player on midroll + if (isChrome) { + resetPlayerOnMidrollCheckboxElement.disabled = true; + resetPlayerOnMidrollCheckboxElement.checked = false; + } else { + resetPlayerOnMidrollCheckboxElement.checked = + store.state.resetPlayerOnMidroll; + resetPlayerOnMidrollCheckboxElement.addEventListener("change", e => { + const checkbox = e.target as HTMLInputElement; + store.state.resetPlayerOnMidroll = checkbox.checked; + }); + } // Disable VOD proxying disableVodRedirectCheckboxElement.checked = store.state.disableVodRedirect; disableVodRedirectCheckboxElement.addEventListener("change", e => { diff --git a/src/options/page.html b/src/options/page.html index 07868314..3586e138 100644 --- a/src/options/page.html +++ b/src/options/page.html @@ -5,50 +5,51 @@ Options - TTV LOL PRO + + +

Options

-
-

Whitelisted channels

-
    -
    - -
    -

    Privacy

    +
    +

    General

    • -
    +
    +

    Whitelisted channels

    +
      +
      +
      -

      Server list

      +

      Proxies

      TTV LOL PRO will ping each server in the list below until one successfully responds to a GET /ping request. @@ -61,12 +62,44 @@

      Server list

      TTV LOL PRO to error out.

      +
        + + Looking for compatible proxies? Check out the "List of other proxies" discussion on TTV LOL PRO's GitHub repository. + +
        Note: Fallback to stream with ads on server error is only available for TTV LOL's API (https://api.ttv.lol).
        -
          +
          + +
          +

          Privacy

          +
            +
          • + + + Recommended +
            + + TTV LOL's API requires your Twitch token (containing sensitive + information) to remove ads from VODs. To protect your privacy, and + since most ad blockers (like uBlock Origin) already remove ads + from VODs, this option is enabled by default. + +
          • +
          diff --git a/src/options/style.css b/src/options/style.css index 4e99e2f6..e8d51223 100644 --- a/src/options/style.css +++ b/src/options/style.css @@ -1,5 +1,6 @@ :root { - --font-primary: "Open Sans", "Segoe UI", sans-serif; + --font-primary: "Inter", "Roobert", "Helvetica Neue", Helvetica, Arial, + sans-serif; --brand-color: #9147ff; --ui-background-color: #151619; @@ -14,9 +15,12 @@ --input-text-secondary: #7a8085; --button-background-color: #353840; - --button-background-color-hover: hsl(224, 9%, 30%); + --button-background-color-hover: #464953; --button-text-primary: #c3c4ca; + --link: #9d5cff; + --link-hover: #af7aff; + --header-height: 2.5rem; } @@ -35,6 +39,17 @@ body { color: #ffffff; } +a, +a:visited { + color: var(--link); + transition: color 100ms ease-in-out; +} + +a:hover, +a:visited:hover { + color: var(--link-hover); +} + input[type="text"], select { height: 30px; @@ -57,7 +72,7 @@ input[type="text"]::placeholder { input[type="button"], button { - padding: 0.5rem 1.25rem; + padding: 0.5rem 1rem; border: 0; border-radius: 6px; background-color: var(--button-background-color); @@ -71,6 +86,10 @@ button:hover { background-color: var(--button-background-color-hover); } +input[type="checkbox"]:disabled + label { + opacity: 0.7; +} + hr { margin: 2.5rem 0; border: 0; diff --git a/src/parcel.d.ts b/src/parcel.d.ts new file mode 100644 index 00000000..7aec3fbf --- /dev/null +++ b/src/parcel.d.ts @@ -0,0 +1,5 @@ +// From https://parceljs.org/features/dependency-resolution/#configuring-other-tools +declare module "url:*" { + const value: string; + export default value; +} diff --git a/src/popup/menu.html b/src/popup/menu.html index 89decab6..c3180e1c 100644 --- a/src/popup/menu.html +++ b/src/popup/menu.html @@ -1,20 +1,31 @@ - + + + + + + Popup - TTV LOL PRO + + + - - -
          A new update is available. Download it from the - + GitHub repo's Releases page
          - +
          diff --git a/src/popup/popup.ts b/src/popup/popup.ts index 245bf1e4..c1a2cea9 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -15,14 +15,6 @@ const whitelistToggle = $("#whitelist-toggle") as HTMLInputElement; const whitelistToggleLabel = $("#whitelist-toggle-label") as HTMLLabelElement; //#endregion -// Open links in new tabs. -document.querySelectorAll("a").forEach(a => { - a.addEventListener("click", e => { - e.preventDefault(); - browser.tabs.create({ url: a.href }); - }); -}); - if (store.readyState === "complete") main(); else store.addEventListener("load", main); diff --git a/src/popup/style.css b/src/popup/style.css index 3833a4a4..c2afe060 100644 --- a/src/popup/style.css +++ b/src/popup/style.css @@ -2,7 +2,8 @@ html { -webkit-font-smoothing: antialiased; background: #151619; color: #c9cbcd; - font-family: "Open Sans", "Segoe UI", sans-serif; + font-family: "Inter", "Roobert", "Helvetica Neue", Helvetica, Arial, + sans-serif; text-rendering: optimizeLegibility; } @@ -103,9 +104,9 @@ body { #stream-status { display: none; margin: 0 30px; - padding: 15px; + padding: 18px 14px 14px 14px; gap: 7px; - border-bottom: 1px solid hsl(210, 4%, 25%); + border-bottom: 1px solid #3d4042; border-radius: 8px; border-bottom-right-radius: 0; border-bottom-left-radius: 0; @@ -127,7 +128,7 @@ body { } #stream-status #proxy-country { display: inline-block; - margin-top: 5px; + margin-top: 10px; font-size: 7pt; opacity: 0.6; } diff --git a/src/store/getDefaultState.ts b/src/store/getDefaultState.ts index 7e0803f4..3f4d01bf 100644 --- a/src/store/getDefaultState.ts +++ b/src/store/getDefaultState.ts @@ -1,4 +1,4 @@ -import { State } from "./types"; +import type { State } from "./types"; export default function getDefaultState() { return { @@ -6,6 +6,7 @@ export default function getDefaultState() { ignoredChannelSubscriptions: [], isUpdateAvailable: false, lastUpdateCheck: 0, + resetPlayerOnMidroll: true, servers: ["https://api.ttv.lol"], streamStatuses: {}, whitelistedChannels: [], diff --git a/src/store/index.ts b/src/store/index.ts index 3feedbd9..72076e6d 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,7 +1,7 @@ import browser from "webextension-polyfill"; import getDefaultState from "./getDefaultState"; import getStateHandler from "./handlers/getStateHandler"; -import { EventType, ReadyState, State, StorageArea } from "./types"; +import type { EventType, ReadyState, State, StorageArea } from "./types"; class Store { private _areaName: StorageArea; diff --git a/src/store/types.ts b/src/store/types.ts index 7b7c65ff..e6282809 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,4 +1,4 @@ -import { StreamStatus } from "../types"; +import type { StreamStatus } from "../types"; export type EventType = "load" | "change"; export type ReadyState = "loading" | "complete"; @@ -9,6 +9,7 @@ export interface State { ignoredChannelSubscriptions: string[]; isUpdateAvailable: boolean; lastUpdateCheck: number; + resetPlayerOnMidroll: boolean; servers: string[]; streamStatuses: Record; whitelistedChannels: string[]; diff --git a/src/types.ts b/src/types.ts index 98fd75e7..3616e474 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,50 @@ +//#region Helpers + // From https://stackoverflow.com/a/51419293 export type KeyOfType = keyof { [P in keyof T as T[P] extends V ? P : never]: any; }; +//#endregion + +//#region Message + +type MessageType = "midroll"; + +export interface Message { + type: MessageType; + request?: Record; + response?: Record; +} + +export interface MidrollMessage extends Message { + type: "midroll"; + response: { + tabId: number; + startDateString: string; + }; +} + +//#endregion + +//#region Stream Status + +export interface StreamStatus { + redirected: boolean; + reason: string; + errors: StreamStatusError[]; + proxyCountry?: string; +} + +export interface StreamStatusError { + timestamp: number; + status: number; +} + +//#endregion + +//#region API + export const enum PlaylistType { Playlist = "playlist", VOD = "vod", @@ -48,14 +90,4 @@ export interface Token { vod_id?: number; } -export interface StreamStatus { - redirected: boolean; - reason: string; - errors: StreamStatusError[]; - proxyCountry?: string; -} - -export interface StreamStatusError { - timestamp: number; - status: number; -} +//#endregion