From 910ab9453bb8de0f70a8ccddd55834049d59ac53 Mon Sep 17 00:00:00 2001 From: Nawid3333 <102778966+Nawid3333@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:47:44 +0100 Subject: [PATCH 1/2] background: stream detection as pure functions in StreamDetection.mjs The first step of splitting background.mjs (4,880 lines). What onHeadersReceived decided about a response - nothing, a subtitle file, or a stream and its player mode - is now classifyResponse(details, firstAddress, matcher), moved with its helpers (urlType, modeFromQuery, isHtmlResponse, isRedirect, redirectedType, modeFromRedirectQuery) and their comments word for word; the custom source patterns and a redirect's first address are parameters instead of module state. The listener keeps its side effects and their order. isSubtitles moved with them (BackgroundUtils.isSubtitles delegates), so the module needs no chrome API and is tested by itself: StreamDetection.test.mjs, 20 cases, one per behaviour the comments record (errors, redirects, CORS preflights, HEAD, pages at a stream's address, a player's text/html manifest, proxies, tracking pixels, redirects to addresses that name nothing, custom patterns). The 272 background tests pass unchanged. background.mjs: -156 lines. Co-Authored-By: Claude Opus 5.5 --- chrome/background/BackgroundUtils.mjs | 5 +- chrome/background/StreamDetection.mjs | 199 ++++++++++++++++++++++++++ chrome/background/background.mjs | 179 ++--------------------- tests/unit/StreamDetection.test.mjs | 62 ++++++++ tests/unit/typeChecked.test.mjs | 1 + 5 files changed, 274 insertions(+), 172 deletions(-) create mode 100644 chrome/background/StreamDetection.mjs create mode 100644 tests/unit/StreamDetection.test.mjs diff --git a/chrome/background/BackgroundUtils.mjs b/chrome/background/BackgroundUtils.mjs index 9e74f2d1..0cced811 100644 --- a/chrome/background/BackgroundUtils.mjs +++ b/chrome/background/BackgroundUtils.mjs @@ -2,6 +2,7 @@ import {EnvUtils} from '../player/utils/EnvUtils.mjs'; import {URLUtils} from '../player/utils/URLUtils.mjs'; import {MpvBackend} from './MpvBackend.mjs'; +import {isSubtitles} from './StreamDetection.mjs'; const PlayerURL = chrome.runtime.getURL('player/index.html'); @@ -225,11 +226,11 @@ export class BackgroundUtils { } /** - * @param {string} ext - A file extension or type (background.mjs urlType). + * @param {string} ext - A file extension or type (StreamDetection.urlType). * @return {boolean} */ static isSubtitles(ext) { - return ext === 'vtt' || ext === 'srt'; + return isSubtitles(ext); } /** diff --git a/chrome/background/StreamDetection.mjs b/chrome/background/StreamDetection.mjs new file mode 100644 index 00000000..1bdcb546 --- /dev/null +++ b/chrome/background/StreamDetection.mjs @@ -0,0 +1,199 @@ +// @ts-check +// How the background tells a stream or a subtitle file from the rest of a page's responses +// (webRequest's onHeadersReceived, in background.mjs): pure functions, the custom source +// patterns and a redirect's first address given to them. Moved out of background.mjs +// (2026-11-07) so they can be tested by themselves (StreamDetection.test.mjs). +import {URLUtils} from '../player/utils/URLUtils.mjs'; +import {modeFromContentType, modeFromMediaType} from './ManifestTypes.mjs'; + +/** @typedef {import('./MultiRegexMatcher.mjs').MultiRegexMatcher} MultiRegexMatcher */ +/** @typedef {{kind: 'ignore'}|{kind: 'subtitles'}|{kind: 'source', mode: string}} ResponseKind */ + +/** + * Whether a file extension or type is a subtitle file's. + * @param {string} ext - A file extension or type (urlType). + * @return {boolean} + */ +export function isSubtitles(ext) { + return ext === 'vtt' || ext === 'srt'; +} + +/** @type {ResponseKind} */ +const IGNORE = Object.freeze({kind: 'ignore'}); + +/** + * What a response webRequest saw is to the detection (background.mjs's onHeadersReceived, which + * acts on it): nothing, a subtitle file, or a stream and the player mode it plays in. + * @param {{url: string, requestId: string|number, statusCode: number, method?: string, type?: string, + * responseHeaders?: Array<{name: string, value?: string}>}} details - webRequest's. + * @param {?string} first - Where the request was first sent, if it was redirected (TabHolder.redirectOrigin). + * @param {MultiRegexMatcher} matcher - The custom source patterns (the options). + * @return {ResponseKind} + */ +export function classifyResponse(details, first, matcher) { + const url = details.url; + + if ((details.statusCode >= 400 && details.statusCode < 600) || details.statusCode === 204) { + return IGNORE; // Client or server error. Ignore it + } + if (isRedirect(details)) { + return IGNORE; // The request's response at the end of the redirect is the stream. + } + // A CORS preflight (OPTIONS), which Firefox shows add-ons, or a HEAD: no stream comes in + // either, and the request after it, which does, has the headers to fetch it with. The + // preflight's were taken for the stream's, a header of the page's own (a token) left out, + // and the server refused the player. + if (details.method === 'OPTIONS' || details.method === 'HEAD') { + return IGNORE; + } + // A page is not a stream, whatever its address or query names: an embed page + // (embed.php?file=https://cdn/.../index.m3u8), or a link run out, answered with the + // hoster's "expired" page at …/clip.mp4. Taking it for the stream opened the player on + // the page itself. The stream a page plays is detected by itself, when the page requests + // it. A page load answered with the stream itself - a proxy link opened in a tab or an + // iframe - is not HTML, and Firefox plays it in that load, with no request of its own to + // detect. Not a player's fetch: a manifest a server calls text/html still plays there. + if ((details.type === 'media' || details.type === 'main_frame' || details.type === 'sub_frame') && + isHtmlResponse(details.responseHeaders)) { + return IGNORE; + } + const ext = redirectedType(first, details, urlType(url, matcher), matcher); + + if (isSubtitles(ext)) { + return {kind: 'subtitles'}; + } + + let mode = URLUtils.getModeFromExtension(ext); + if (!mode) { + // A manifest whose URL names no type: its Content-Type does (ManifestTypes). + mode = modeFromContentType(details.responseHeaders); + } + if (!mode) { + if (details.type === 'media') { + mode = modeFromMediaType(details.responseHeaders); + if (!mode) { + return IGNORE; + } + } else if (details.type === 'xmlhttprequest' || details.type === 'other' || + details.type === 'main_frame' || details.type === 'sub_frame') { + // The query names the stream for a proxy a player fetches it through, and for a + // page load answered with the stream itself (not HTML, above). Nothing else: a + // tracking pixel, script or beacon often names the media URL it reports on in its + // query, and was taken for the stream - opened in the player, sent to mpv ahead of + // the real manifest. Or the query of the proxy link redirected here. + mode = modeFromQuery(url, matcher) || modeFromRedirectQuery(first, details, matcher); + if (!mode) { + return IGNORE; + } + } else { + return IGNORE; + } + } + return {kind: 'source', mode}; +} + +/** + * The type a URL names by itself: its file extension, or what a site's pattern says it + * is (Vimeo's player config, the user's custom source patterns). + * @param {string} url - A request's URL. + * @param {MultiRegexMatcher} matcher - The custom source patterns (the options). + * @return {string} The extension or type, for URLUtils.getModeFromExtension and + * BackgroundUtils.isSubtitles. + */ +export function urlType(url, matcher) { + let ext = URLUtils.get_url_extension(url); + if (URLUtils.hostnameMatches(url, 'player.vimeo.com') && (url.includes('config?') || url.includes('video'))) { + ext = 'vmpatch'; + } + + const output = matcher.match(url); + if (output) { + ext = output; + } + return ext; +} +/** + * The mode of a stream fetched through a proxy, which names it in the query string: + * proxy?url=https://cdn/.../index.m3u8. + * @param {string} url - The proxy request's URL. + * @param {MultiRegexMatcher} matcher - The custom source patterns (the options). + * @return {?string} The stream's player mode, or null when the query names none. + */ +export function modeFromQuery(url, matcher) { + for (const value of URLUtils.get_url_params(url).values()) { + if (!URLUtils.is_url(value)) continue; + let ext = URLUtils.get_url_extension(value); + + const output = matcher.match(value); + if (output) { + ext = output; + } + + const mode = URLUtils.getModeFromExtension(ext); + if (mode) { + return mode; + } + } + return null; +} +/** + * Whether a response is an HTML page, by its Content-Type. + * @param {Array<{name: string, value?: string}>} [headers] - webRequest's responseHeaders. + * @return {boolean} True for text/html and application/xhtml+xml. + */ +export function isHtmlResponse(headers) { + const contentType = headers?.find((header) => header.name.toLowerCase() === 'content-type'); + const type = contentType?.value?.split(';')[0].trim().toLowerCase(); + return type === 'text/html' || type === 'application/xhtml+xml'; +} +/** + * Whether a response is a redirect, which the request follows to another address: no stream, + * though its address names one (a hoster's …/clip.mp4 link to its CDN). Taken for the stream, + * the player played the link, and the redirect at each of its requests went to the CDN without + * the page's Referer, which was set for the link (refused by a CDN that checks it); a CDN slower + * than the player's opening delay was never seen, the frame having a player by its answer; and a + * link run out, redirected to the hoster's "expired" page, opened a player on it (upstream #585). + * Only the statuses Firefox follows, to a Location: without one, the response is the file + * (glm's review, 2026-10-28). 304 Not Modified is the file itself, from the cache. + * @param {{statusCode: number, responseHeaders?: Array<{name: string, value?: string}>}} details - + * webRequest's. + * @return {boolean} + */ +export function isRedirect(details) { + return [301, 302, 303, 307, 308].includes(details.statusCode) && + !!details.responseHeaders?.some((header) => header.name.toLowerCase() === 'location'); +} +/** + * The type a redirected request's first address names, for a response whose own address names + * none: …/master.m3u8 redirected to the CDN's /v/7?token. Not for a page (the link run out, sent + * to the hoster's "expired" page), nor a manifest its Content-Type names, nor a media element's + * file, whose Content-Type tells video from audio (modeFromMediaType). + * @param {?string} first - The redirected request's first address (TabHolder.redirectOrigin), or null. + * @param {{requestId: string|number, type?: string, responseHeaders?: Array<{name: string, value?: string}>}} details - + * webRequest's. + * @param {string} ext - What the response's own address names (urlType). + * @param {MultiRegexMatcher} matcher - The custom source patterns (the options). + * @return {string} The type to go by: ext, or the first address's. + */ +export function redirectedType(first, details, ext, matcher) { + const namesType = (/** @type {string} */ type) => !!URLUtils.getModeFromExtension(type) || isSubtitles(type); + if (!first || namesType(ext) || details.type === 'media' || isHtmlResponse(details.responseHeaders) || + modeFromContentType(details.responseHeaders)) { + return ext; + } + const firstType = urlType(first, matcher); + return namesType(firstType) ? firstType : ext; +} +/** + * The mode the query of a redirected request's first address names (modeFromQuery), for a + * proxy link redirected to an address that names none: proxy?url=https://cdn/.../index.m3u8 to + * the CDN's /s/9f3e, answered application/octet-stream. Not for a page it led to. + * @param {?string} first - The redirected request's first address (TabHolder.redirectOrigin), or null. + * @param {{requestId: string|number, responseHeaders?: Array<{name: string, value?: string}>}} details - + * webRequest's. + * @param {MultiRegexMatcher} matcher - The custom source patterns (the options). + * @return {?string} + */ +export function modeFromRedirectQuery(first, details, matcher) { + return first && !isHtmlResponse(details.responseHeaders) ? modeFromQuery(first, matcher) : null; +} diff --git a/chrome/background/background.mjs b/chrome/background/background.mjs index be91823a..7c7f0638 100644 --- a/chrome/background/background.mjs +++ b/chrome/background/background.mjs @@ -16,13 +16,13 @@ import {parseCustomSourcePatterns} from './CustomSourcePatterns.mjs'; import {sanitizeDownloadFilename} from './DownloadFilename.mjs'; import {fetchFreshLink} from './FreshLink.mjs'; import {KeyShortcut} from './KeyShortcut.mjs'; -import {modeFromContentType, modeFromMediaType} from './ManifestTypes.mjs'; import {MessageTypes} from '../player/enums/MessageTypes.mjs'; import {Capabilities, MpvBackend} from './MpvBackend.mjs'; import {MpvSubtitles} from './MpvSubtitles.mjs'; import {MultiRegexMatcher} from './MultiRegexMatcher.mjs'; import {RuleManager} from './NetRequestRuleManager.mjs'; import {StreamLengths} from './StreamLengths.mjs'; +import {classifyResponse, modeFromQuery, urlType} from './StreamDetection.mjs'; import {TabTracker} from './TabTracker.mjs'; import {UrlMatchList} from './UrlMatchList.mjs'; import {VpnProxyMirror} from './VpnProxyMirror.mjs'; @@ -2192,49 +2192,6 @@ async function attachPageQualities(tab, sources) { } } -/** - * The type a URL names by itself: its file extension, or what a site's pattern says it - * is (Vimeo's player config, the user's custom source patterns). - * @param {string} url - A request's URL. - * @return {string} The extension or type, for URLUtils.getModeFromExtension and - * BackgroundUtils.isSubtitles. - */ -function urlType(url) { - let ext = URLUtils.get_url_extension(url); - if (URLUtils.hostnameMatches(url, 'player.vimeo.com') && (url.includes('config?') || url.includes('video'))) { - ext = 'vmpatch'; - } - - const output = CustomSourcePatternsMatcher.match(url); - if (output) { - ext = output; - } - return ext; -} - -/** - * The mode of a stream fetched through a proxy, which names it in the query string: - * proxy?url=https://cdn/.../index.m3u8. - * @param {string} url - The proxy request's URL. - * @return {?string} The stream's player mode, or null when the query names none. - */ -function modeFromQuery(url) { - for (const value of URLUtils.get_url_params(url).values()) { - if (!URLUtils.is_url(value)) continue; - let ext = URLUtils.get_url_extension(value); - - const output = CustomSourcePatternsMatcher.match(value); - if (output) { - ext = output; - } - - const mode = URLUtils.getModeFromExtension(ext); - if (mode) { - return mode; - } - } - return null; -} /** @typedef {import('./TabTracker.mjs').FrameHolder} FrameHolder */ /** @typedef {import('./TabTracker.mjs').TabHolder} TabHolder */ @@ -2412,13 +2369,13 @@ function recoverFrameSources(frame, msg) { const media = !!resource.media; // A media element sends no Origin, unless it loads CORS (crossorigin), as content.js says. const headers = pageHeaders(typeof msg.url === 'string' ? msg.url : frame.url, url, media && !resource.cors); - const ext = urlType(url); + const ext = urlType(url, CustomSourcePatternsMatcher); if (BackgroundUtils.isSubtitles(ext)) { handleSubtitles(url, frame, headers); continue; } - const mode = URLUtils.getModeFromExtension(ext) || (media ? PlayerModes.ACCELERATED_MP4 : modeFromQuery(url)); + const mode = URLUtils.getModeFromExtension(ext) || (media ? PlayerModes.ACCELERATED_MP4 : modeFromQuery(url, CustomSourcePatternsMatcher)); if (!mode || getSourceFromURL(frame, url)) continue; onSourceRecieved({ url, @@ -4666,16 +4623,6 @@ const webRequestPerms = ['requestHeaders']; /** @type {Array<'responseHeaders'|'extraHeaders'|'blocking'>} */ const webRequestPerms2 = ['responseHeaders']; -/** - * Whether a response is an HTML page, by its Content-Type. - * @param {Array<{name: string, value?: string}>} [headers] - webRequest's responseHeaders. - * @return {boolean} True for text/html and application/xhtml+xml. - */ -function isHtmlResponse(headers) { - const contentType = headers?.find((header) => header.name.toLowerCase() === 'content-type'); - const type = contentType?.value?.split(';')[0].trim().toLowerCase(); - return type === 'text/html' || type === 'application/xhtml+xml'; -} /** * Whether a request belongs to no tab: a service worker's, or this background's own @@ -4724,131 +4671,23 @@ chrome.webRequest.onBeforeRedirect.addListener((details) => { types: ['media', 'xmlhttprequest', 'other', 'main_frame', 'sub_frame'], }); -/** - * Whether a response is a redirect, which the request follows to another address: no stream, - * though its address names one (a hoster's …/clip.mp4 link to its CDN). Taken for the stream, - * the player played the link, and the redirect at each of its requests went to the CDN without - * the page's Referer, which was set for the link (refused by a CDN that checks it); a CDN slower - * than the player's opening delay was never seen, the frame having a player by its answer; and a - * link run out, redirected to the hoster's "expired" page, opened a player on it (upstream #585). - * Only the statuses Firefox follows, to a Location: without one, the response is the file - * (glm's review, 2026-10-28). 304 Not Modified is the file itself, from the cache. - * @param {{statusCode: number, responseHeaders?: Array<{name: string, value?: string}>}} details - - * webRequest's. - * @return {boolean} - */ -function isRedirect(details) { - return [301, 302, 303, 307, 308].includes(details.statusCode) && - !!details.responseHeaders?.some((header) => header.name.toLowerCase() === 'location'); -} - -/** - * The type a redirected request's first address names, for a response whose own address names - * none: …/master.m3u8 redirected to the CDN's /v/7?token. Not for a page (the link run out, sent - * to the hoster's "expired" page), nor a manifest its Content-Type names, nor a media element's - * file, whose Content-Type tells video from audio (modeFromMediaType). - * @param {TabHolder} tab - * @param {{requestId: string|number, type?: string, responseHeaders?: Array<{name: string, value?: string}>}} details - - * webRequest's. - * @param {string} ext - What the response's own address names (urlType). - * @return {string} The type to go by: ext, or the first address's. - */ -function redirectedType(tab, details, ext) { - const namesType = (/** @type {string} */ type) => !!URLUtils.getModeFromExtension(type) || BackgroundUtils.isSubtitles(type); - const first = tab.redirectOrigin(details.requestId); - if (!first || namesType(ext) || details.type === 'media' || isHtmlResponse(details.responseHeaders) || - modeFromContentType(details.responseHeaders)) { - return ext; - } - const firstType = urlType(first); - return namesType(firstType) ? firstType : ext; -} - -/** - * The mode the query of a redirected request's first address names (modeFromQuery), for a - * proxy link redirected to an address that names none: proxy?url=https://cdn/.../index.m3u8 to - * the CDN's /s/9f3e, answered application/octet-stream. Not for a page it led to. - * @param {TabHolder} tab - * @param {{requestId: string|number, responseHeaders?: Array<{name: string, value?: string}>}} details - - * webRequest's. - * @return {?string} - */ -function modeFromRedirectQuery(tab, details) { - const first = tab.redirectOrigin(details.requestId); - return first && !isHtmlResponse(details.responseHeaders) ? modeFromQuery(first) : null; -} chrome.webRequest.onHeadersReceived.addListener( (details) => { if (isTablessRequest(details)) { return; } - const url = details.url; const tab = Tabs.getTabOrCreate(details.tabId); const frame = tab.getFrameOrCreate(details.frameId); if (frame.hasPlayer()) return; - - if ((details.statusCode >= 400 && details.statusCode < 600) || details.statusCode === 204) { - return; // Client or server error. Ignore it - } - if (isRedirect(details)) { - return; // The request's response at the end of the redirect is the stream. - } - // A CORS preflight (OPTIONS), which Firefox shows add-ons, or a HEAD: no stream comes in - // either, and the request after it, which does, has the headers to fetch it with. The - // preflight's were taken for the stream's, a header of the page's own (a token) left out, - // and the server refused the player. - if (details.method === 'OPTIONS' || details.method === 'HEAD') { - return; - } - // A page is not a stream, whatever its address or query names: an embed page - // (embed.php?file=https://cdn/.../index.m3u8), or a link run out, answered with the - // hoster's "expired" page at …/clip.mp4. Taking it for the stream opened the player on - // the page itself. The stream a page plays is detected by itself, when the page requests - // it. A page load answered with the stream itself - a proxy link opened in a tab or an - // iframe - is not HTML, and Firefox plays it in that load, with no request of its own to - // detect. Not a player's fetch: a manifest a server calls text/html still plays there. - if ((details.type === 'media' || details.type === 'main_frame' || details.type === 'sub_frame') && - isHtmlResponse(details.responseHeaders)) { - return; - } - const ext = redirectedType(tab, details, urlType(url)); - - if (BackgroundUtils.isSubtitles(ext)) { + const found = classifyResponse(details, tab.redirectOrigin(details.requestId), CustomSourcePatternsMatcher); + if (found.kind === 'subtitles') { VpnMirror.noteSource(details); - handleSubtitles(url, frame, tab.requestHeaders.get(details.requestId)); - return; - } - - let mode = URLUtils.getModeFromExtension(ext); - if (!mode) { - // A manifest whose URL names no type: its Content-Type does (ManifestTypes). - mode = modeFromContentType(details.responseHeaders); - } - if (!mode) { - if (details.type === 'media') { - mode = modeFromMediaType(details.responseHeaders); - if (!mode) { - return; - } - } else if (details.type === 'xmlhttprequest' || details.type === 'other' || - details.type === 'main_frame' || details.type === 'sub_frame') { - // The query names the stream for a proxy a player fetches it through, and for a - // page load answered with the stream itself (not HTML, above). Nothing else: a - // tracking pixel, script or beacon often names the media URL it reports on in its - // query, and was taken for the stream - opened in the player, sent to mpv ahead of - // the real manifest. Or the query of the proxy link redirected here. - mode = modeFromQuery(url) || modeFromRedirectQuery(tab, details); - if (!mode) { - return; - } - } else { - return; - } + handleSubtitles(details.url, frame, tab.requestHeaders.get(details.requestId)); + } else if (found.kind === 'source') { + VpnMirror.noteSource(details); + onSourceRecieved(details, frame, found.mode); } - - VpnMirror.noteSource(details); - onSourceRecieved(details, frame, mode); }, { urls: [''], }, webRequestPerms2, diff --git a/tests/unit/StreamDetection.test.mjs b/tests/unit/StreamDetection.test.mjs new file mode 100644 index 00000000..e4857d25 --- /dev/null +++ b/tests/unit/StreamDetection.test.mjs @@ -0,0 +1,62 @@ +import {describe, expect, it} from 'vitest'; +import {classifyResponse} from '../../chrome/background/StreamDetection.mjs'; +import {MultiRegexMatcher} from '../../chrome/background/MultiRegexMatcher.mjs'; + +// What the background makes of a page's responses (webRequest's onHeadersReceived): nothing, +// a subtitle file, or a stream and its player mode. Each row is a case the code's comments +// record; the listener in background.mjs only acts on the answer (backgroundDetection.test.mjs +// runs it with the rest of the background). + +const none = new MultiRegexMatcher(); +none.compile(); + +/** + * A response as webRequest gives it. + * @param {string} url + * @param {Object} [over] - Other fields: statusCode, method, type, headers (name -> value). + * @return {Object} + */ +function response(url, {statusCode = 200, method = 'GET', type = 'xmlhttprequest', headers = {}} = {}) { + return { + url, requestId: 1, statusCode, method, type, + responseHeaders: Object.entries(headers).map(([name, value]) => ({name, value})), + }; +} + +const HLS = {kind: 'source', mode: 'accelerated_hls'}; +const DASH = {kind: 'source', mode: 'accelerated_dash'}; +const IGNORE = {kind: 'ignore'}; + +describe('classifyResponse', () => { + it.each([ + ['an HLS playlist by its extension', response('https://cdn.test/v/master.m3u8'), null, HLS], + ['a DASH manifest by its extension', response('https://cdn.test/v/manifest.mpd'), null, DASH], + ['a manifest only its Content-Type names', response('https://cdn.test/v/7', {headers: {'Content-Type': 'application/vnd.apple.mpegurl'}}), null, HLS], + ['a subtitle file', response('https://cdn.test/subs/en.vtt'), null, {kind: 'subtitles'}], + ['a client or server error', response('https://cdn.test/v/master.m3u8', {statusCode: 404}), null, IGNORE], + ['a 204', response('https://cdn.test/v/master.m3u8', {statusCode: 204}), null, IGNORE], + ['a redirect, whose end is the stream', response('https://host.test/clip.m3u8', {statusCode: 302, headers: {Location: 'https://cdn.test/a'}}), null, IGNORE], + ['a 302 without a Location: the file itself', response('https://cdn.test/v/master.m3u8', {statusCode: 302}), null, HLS], + ['a CORS preflight', response('https://cdn.test/v/master.m3u8', {method: 'OPTIONS'}), null, IGNORE], + ['a HEAD request', response('https://cdn.test/v/master.m3u8', {method: 'HEAD'}), null, IGNORE], + ['a page at a stream\'s address (the "expired" page)', response('https://host.test/clip.m3u8', {type: 'main_frame', headers: {'Content-Type': 'text/html; charset=utf-8'}}), null, IGNORE], + ['a media element\'s HTML answer', response('https://host.test/clip.m3u8', {type: 'media', headers: {'Content-Type': 'text/html'}}), null, IGNORE], + ['a player\'s fetch of a manifest the server calls text/html', response('https://cdn.test/v/master.m3u8', {headers: {'Content-Type': 'text/html'}}), null, HLS], + ['a proxy a player fetches the stream through', response('https://proxy.test/get?url=https%3A%2F%2Fcdn.test%2Fv%2Fmaster.m3u8'), null, HLS], + ['a tracking pixel naming the stream in its query', response('https://track.test/px?u=https%3A%2F%2Fcdn.test%2Fv%2Fmaster.m3u8', {type: 'image'}), null, IGNORE], + ['a stream redirected to an address that names none', response('https://cdn.test/v/7?token=1', {headers: {'Content-Type': 'application/octet-stream'}}), 'https://host.test/v/master.m3u8', HLS], + ['a proxy link redirected to an address that names none', response('https://cdn.test/s/9f3e', {headers: {'Content-Type': 'application/octet-stream'}}), 'https://proxy.test/p?url=https%3A%2F%2Fcdn.test%2Fv%2Fmaster.m3u8', HLS], + ['a redirect\'s end that is a page', response('https://host.test/expired', {type: 'main_frame', headers: {'Content-Type': 'text/html'}}), 'https://host.test/clip.m3u8', IGNORE], + ['nothing a player plays', response('https://cdn.test/app.js', {type: 'script'}), null, IGNORE], + ])('%s', (_, details, first, expected) => { + expect(classifyResponse(details, first, none)).toEqual(expected); + }); + + it('takes the type a custom source pattern names', () => { + const matcher = new MultiRegexMatcher(); + matcher.addRegex('^https://stream\\.test/live/', '', 'm3u8'); + matcher.compile(); + expect(classifyResponse(response('https://stream.test/live/channel-7'), null, matcher)).toEqual(HLS); + expect(classifyResponse(response('https://stream.test/live/channel-7'), null, none)).toEqual(IGNORE); + }); +}); diff --git a/tests/unit/typeChecked.test.mjs b/tests/unit/typeChecked.test.mjs index 3950b655..196a888d 100644 --- a/tests/unit/typeChecked.test.mjs +++ b/tests/unit/typeChecked.test.mjs @@ -25,6 +25,7 @@ const CHECKED = [ 'chrome/background/MpvTarget.mjs', 'chrome/background/MultiRegexMatcher.mjs', 'chrome/background/NetRequestRuleManager.mjs', + 'chrome/background/StreamDetection.mjs', 'chrome/background/StreamLengths.mjs', 'chrome/background/TabTracker.mjs', 'chrome/background/UrlMatchList.mjs', From d6c41227e75bb476f76bd5d3b1e5e34a9bc0d6b0 Mon Sep 17 00:00:00 2001 From: Nawid3333 <102778966+Nawid3333@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:53:09 +0100 Subject: [PATCH 2/2] background: MPV mode in MpvMode.mjs (1,267 lines out of background.mjs) The second step of splitting background.mjs: the 48 top-level declarations of MPV mode - the hand-off (openInMpv, autoOpenInMpv, sendPlayedToMpv, openMpvWithSources), the toggle (startMpv, stopMpv, onToggleMpv, leaveMpvWhenOff, inheritMpv), the autoplay watch over the next episode (mpvWatch, watchMpvFor, its countdown, goToEpisode...), the decoder and error reports, and tabSources (it sets mpvWatch) - moved with their comments by a script that cuts each declaration from the syntax tree, exports it, and writes both modules' imports from the reference graph. Its guards held: no moved variable is assigned outside the move (the LastManualMpv* the message handler sets stay), and no moved initializer reads background.mjs while the modules load, so the import cycle (MpvMode uses background.mjs's state and helpers, which it exports now; background.mjs registers the listeners at its top level, as the event page needs) is safe. Typedefs imported across. background.mjs: 4,724 -> 3,476 lines. The 294 background tests pass unchanged (UrlMatchList.test reads both files now). Co-Authored-By: Claude Opus 5.5 --- chrome/background/MpvMode.mjs | 1273 +++++++++++++++++++++++++++++ chrome/background/background.mjs | 1306 +----------------------------- tests/unit/UrlMatchList.test.mjs | 6 +- tests/unit/typeChecked.test.mjs | 1 + 4 files changed, 1310 insertions(+), 1276 deletions(-) create mode 100644 chrome/background/MpvMode.mjs diff --git a/chrome/background/MpvMode.mjs b/chrome/background/MpvMode.mjs new file mode 100644 index 00000000..87b9c175 --- /dev/null +++ b/chrome/background/MpvMode.mjs @@ -0,0 +1,1273 @@ +// @ts-check +// MPV mode: handing a page's video to mpv (the native host, MpvBackend), and what follows it - +// the autoplay watch over the page's next episode, its countdown, the hand-off's results +// (decoder, errors) and the toolbar's MPV toggle. Moved out of background.mjs (2026-11-07), +// whose shared state and helpers it imports from there; background.mjs registers the +// listeners (they have to be at its top level for the event page to wake for them). +import {MessageTypes} from '../player/enums/MessageTypes.mjs'; +import {PlayerModes} from '../player/enums/PlayerModes.mjs'; +import {ProblemKinds} from '../player/utils/ProblemLog.mjs'; +import {SHORT_S} from '../player/utils/StreamPick.mjs'; +import {BackgroundUtils} from './BackgroundUtils.mjs'; +import {Capabilities, MpvBackend} from './MpvBackend.mjs'; +import {UrlMatchList} from './UrlMatchList.mjs'; +import {AutoEnableList, CaptionScrapeWaitMs, HlsQualitiesWaitMs, Lengths, Logging, Options, PageSubtitles, SourceLengthWaitMs, Tabs, VpnMirror, askFrame, closeReopenedTab, ensureOptions, hlsQualityChoice, isSentStream, isTabOpen, newestOfLongest, noteProblem, notifySeriesEnd, pageQualityChoice, pauseTabMedia, saveOption, scrapeCaptionsTags, startPagePlayers, tabHasPlayer, tabTitle, takeScrapedCaptions, userTurnedOff} from './background.mjs'; + +/** @typedef {import('./TabTracker.mjs').TabHolder} TabHolder */ +/** @typedef {import('./TabTracker.mjs').TrackedSource} TrackedSource */ +/** @typedef {import('./background.mjs').TabExtras} TabExtras */ +/** @typedef {import('./background.mjs').Continuation} Continuation */ +/** @typedef {import('./background.mjs').MpvHandOff} MpvHandOff */ + +export const MpvAllowlist = new UrlMatchList(); + +export const Mpv = new MpvBackend(); + +// The shortcut's MPV waits for the user to start a video (onUserPlay). A play +// whose stream is not detected yet takes the next one within MpvPlayPendingMs; +// the same video played again within MpvPlayRepeatMs is the player repeating +// itself, not a second request. +export const MpvPlayPendingMs = 15000; + +export const MpvPlayRepeatMs = 10000; + +// The allowlist's MPV, on a page Back gave back: how long a play whose stream is not +// known waits for it to be detected before the page's known stream goes +// (autoOpenKnownLater). 1 s (3 s until 2026-10-14): a play-triggered fetch lands within a +// few hundred ms, and every Back + play waited the whole time before mpv started. +export const MpvPlayKnownAfterMs = 1000; + +/** + * Resolves the anime/movie shader hint sent to mpv for a stream. + * + * Movie is the fallback on purpose: most sites on the MPV Allowlist are + * movie sites for this user, so only the anime ones need an `@anime` tag -- + * an untagged site, or none of this ever matching, still gets an explicit + * `movie` rather than nothing (which would leave it to mpv's own, less + * reliable folder-name heuristic). + * + * @param {?string} [explicit] - The player's manual per-video override + * (SaveManager.mjs's mpvContentType), or anything else to fall through. + * @param {string} [url] - Tab URL to check against the MPV Allowlist. + * @return {string} 'anime' or 'movie', never null/undefined. + */ +export function resolveMpvContentType(explicit, url) { + if (explicit === 'anime' || explicit === 'movie') { + return explicit; + } + return MpvAllowlist.getContentType(url) || 'movie'; +} + +/** + * Puts a tab in MPV mode: FastStream on, streams handed to mpv. + * + * An active in-page player has to come down first - the same reload the plain + * Off/On toggle uses to undo one - because a FastStream overlay isn't + * something a message can cleanly retract. isMpv is set before the reload, so + * the freshly (re-)detected source is what onSourceRecieved forwards to mpv + * once the page reloads; nothing here has to redo that forwarding itself. + * + * With onPlay (the shortcut) nothing is handed off here or by the page's own + * stream requests: the tab waits for the user to start a video (onUserPlay). + * A video they started and are still watching counts as started now. + * + * What the button said of the last hand-off - its failure, an outdated host, mpv's + * decoder - goes: it is about a stream sent before, and after MPV off and on again it + * showed with nothing sent since. The next hand-off says it again. + * + * @param {TabHolder & TabExtras} tab - The tab to switch. + * @param {boolean} [onPlay] - Wait for the user to start a video. + * @return {Promise} + */ +export async function startMpv(tab, onPlay = false) { + tab.isOn = true; + tab.isMpv = true; + tab.mpvOnPlay = onPlay; + // The user's own MPV (the MPV key, the toolbar's On -> MPV), not a list's start: it stays + // for the tab (his call, 2026-10-10). Still marked as the list's, it went off on the first + // page outside the lists of a site an auto-enable list had turned on (glm's review, + // 2026-10-13). + tab.regexMatched = false; + tab.mpvTurnedOff = false; + tab.mpvError = null; + tab.mpvHostOutdated = false; + tab.mpvDecoder = null; + tab.mpvDecoderQuery = null; + BackgroundUtils.updateTabIcon(tab); + + if (await tabHasPlayer(tab)) { + tab.resetForReload(); + chrome.tabs.reload(tab.tabId, () => BackgroundUtils.checkMessageError('reload', true)); // closed meanwhile: no unhandled error + } else if (onPlay) { + // A fresh start: the same video may go to mpv again. + tab.mpvSentUrls.clear(); + tab.mpvLastPlaySend = null; + tab.mpvPlayPendingUntil = 0; + tab.mpvPlayedVideo = null; + tab.mpvPlayChecking = null; + chrome.tabs.sendMessage(tab.tabId, { + type: MessageTypes.MPV_REPORT_PLAYING, + }, () => { + // A blank tab, or a page without the content script, is normal here. + BackgroundUtils.checkMessageError('mpv_report_playing'); + }); + } else { + // No player was ever requested for this page - hand off directly from + // whatever sources are already tracked. + openMpvWithSources(tab); + } +} + +/** + * Takes a tab out of MPV mode and turns FastStream off for it, tearing down + * any in-page player that is up or on its way. + * + * @param {TabHolder} tab - The tab to switch. + * @return {Promise} + */ +export async function stopMpv(tab) { + tab.isOn = false; + tab.isMpv = false; + tab.mpvOnPlay = false; + userTurnedOff(tab); + BackgroundUtils.updateTabIcon(tab); + + if (await tabHasPlayer(tab)) { + tab.resetForReload(); + chrome.tabs.reload(tab.tabId, () => BackgroundUtils.checkMessageError('reload', true)); // closed meanwhile: no unhandled error + } +} + +/** + * Takes every tab out of MPV while MPV mode is off. That option is the switch for the + * whole mpv integration, and a tab keeps its mode until something changes it: one in MPV + * when the option was switched off, or put back so by a woken background, went on handing + * its streams to mpv - on a site on both the MPV Allowlist and the auto-enable list, page + * after page. Such a tab keeps FastStream on where the auto-enable list would turn it on, + * for the in-page player, and is off everywhere else, as after the toolbar's MPV -> Off. + * MPV opens no in-page player, so there is none to take down. + */ +export function leaveMpvWhenOff() { + if (Options.mpvMode) { + return; + } + for (const tab of Tabs.tabs.values()) { + if (!tab.isMpv) { + continue; + } + tab.isMpv = false; + tab.mpvOnPlay = false; + tab.isOn = tab.isOn && !!tab.url && AutoEnableList.matches(tab.url); + BackgroundUtils.updateTabIcon(tab, true); + Tabs.saveTabState(tab); + } +} + +/** + * The toggle_mpv shortcut (Alt+F unless the user rebinds it; Ctrl+Shift+U until + * 2026-10-04): MPV on or + * off for the tab, on any site. Unlike the toolbar cycle it does not need the + * site on the MPV Allowlist - pressing it is the deliberate choice the + * allowlist otherwise makes for the user. Off means FastStream off, the same + * as the toolbar's MPV -> Off step. From the in-page player it goes straight to + * MPV, as the player's key goes from MPV straight to the player (onClicked). + * + * Only a video the user starts goes to mpv (startMpv's onPlay, onUserPlay): + * on an arbitrary site the first stream a page loads is as likely a preview + * or a background clip as the video they came for. + * + * On a blank or new tab it arms MPV for the tab instead: nothing is playing + * yet, and the tab's mode survives navigation, so the first video of the next + * page opened there goes to mpv (new tab, Alt+F, paste a link). The + * toolbar opens the player page on a blank tab; that page has no video to send. + * On the player page itself it does nothing. + * + * Does nothing while MPV mode is off: that option is the switch for the whole + * mpv integration, and openMpvWithSources refuses without it. + * + * @param {chrome.tabs.Tab} tabobj - The tab that was active when the key was pressed. + */ +export async function onToggleMpv(tabobj) { + await ensureOptions(); + + if (!Options.mpvMode) { + return; + } + + const hasPerms = await BackgroundUtils.checkPermissions(); + if (!hasPerms) { + chrome.tabs.create({ + url: chrome.runtime.getURL('perms.html'), + }).catch((e) => console.error('Opening the permissions page failed', e)); + return; + } + + const tab = Tabs.getTabOrCreate(/** @type {number} */ (tabobj.id)); + if (tabobj.url) { + tab.url = tabobj.url; + } + + if (tab.url && BackgroundUtils.isUrlPlayerUrl(tab.url)) { + return; + } + + if (tab.isOn && tab.isMpv) { + await stopMpv(tab); + } else { + await startMpv(tab, true); + } + + Tabs.saveTabState(tab); +} + +/** + * A tab a tab in MPV opened - a site's player in a pop-up tab, an episode middle-clicked - + * starts in MPV as well, the MPV key's way: only a video the user starts there goes to mpv + * (#337). MPV belongs to a tab, and the new one had none, so its video played in the + * browser. Never the allowlist's automatic first stream: most pop-ups are ads, and an + * autoplaying one would go to mpv. Firefox names the opener (openerTabId) for a tab, not + * for a pop-up window, which this cannot reach (measured 2026-10-04). + * @param {TabHolder} openerTab - The tab that opened it. + * @param {number} tabId - The new tab. + */ +export function inheritMpv(openerTab, tabId) { + // isMpv only with isOn: every switch to Off clears both. + if (!Options.mpvMode || !openerTab.isMpv) { + return; + } + const tab = Tabs.getTabOrCreate(tabId); + tab.isOn = true; + tab.isMpv = true; + tab.mpvOnPlay = true; + BackgroundUtils.updateTabIcon(tab); + Tabs.saveTabState(tab); +} + +/** + * Notes a next episode whose page brought no video within a minute (ProblemLog): the + * continuation is still there, as nothing took it (its sources, or mpv's stream). + * @param {TabHolder} tab + * @param {Continuation} continuation + * @param {'player'|'mpv'} mode + */ +export function watchNextEpisodeArrives(tab, continuation, mode) { + const from = tab.url; + setTimeout(() => { + if (tab.continuationOptions !== continuation || !Tabs.tabs.has(tab.tabId) || tab.url === from) return; + noteProblem(tab.tabId, {url: tab.url, kind: ProblemKinds.NO_STREAM, mode}).catch((e) => console.error(e)); + }, MpvNextEpisodeWaitMs); +} + +/** + * A tab's subtitles, as SubRip for mpv: the subtitle files its page asked for and its + * elements, in every frame - what the in-page player would show from that page. A + * frame that does not tell its elements within CaptionScrapeWaitMs keeps those it + * had. + * @param {TabHolder} tab + * @return {Promise>} + */ +export async function pageSubtitlesForMpv(tab) { + const frames = [...tab.getFrames()]; + await Promise.all(frames.map(async (frame) => { + let timer; + const subs = await Promise.race([ + scrapeCaptionsTags(frame), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), CaptionScrapeWaitMs); + }), + ]); + clearTimeout(timer); + takeScrapedCaptions(frame, subs); + })); + return PageSubtitles.toSrt(frames.flatMap((frame) => frame.getSubtitles())); +} + +/** + * Pauses the page after a hand-off the host says worked, and logs how raising mpv's window + * went (the host's focus, foreground and reused). Not when the user turned MPV off since + * the stream went (userTurnedOff): mpv got it all the same, but the page they went back to + * plays on. A fresh mpv answers seconds after the send. + * @param {TabHolder} tab - The tab the stream was sent from. + * @param {{focus?: string, foreground?: string, reused?: boolean}} result - The host's answer. + */ +export function pauseAfterHandOff(tab, result) { + if (Logging) { + console.log('[MPV] mpv window: reused', result.reused === true, 'focus', result.focus, 'foreground', result.foreground); + } + if (tab.isOn && tab.isMpv) { + pauseTabMedia(tab.tabId); + } +} + +/** + * The allowlist's MPV: hands a page's stream to mpv, once per page + * (tab.mpvAutoOpened), then pauses the page. + * + * @param {TabHolder} tab - The tab the stream is in. + * @param {string} url - The stream. + * @param {*} headers - Its request headers, as detected. + */ +export function autoOpenInMpv(tab, url, headers) { + // Reached after awaits (options, a stream's length): a tab closed meanwhile sends nothing. + if (!isTabOpen(tab)) { + return; + } + tab.mpvAutoOpened = true; + Tabs.saveTabState(tab); + const page = tab.mpvPage; + if (Logging) console.log('[MPV] forwarding detected stream to mpv:', url); + tabTitle(tab.tabId).then((title) => + openInMpv(tab.tabId, url, tab, headers, resolveMpvContentType(null, tab.url), tab.url, title)).then((result) => { + if (Logging) console.log('[MPV] forward result:', url, JSON.stringify(result)); + if (!isHandOffPage(tab, page)) { + return; + } + setMpvError(tab, result); + followMpvDecoder(tab, result); + if (result.ok) { + pauseAfterHandOff(tab, result); + } else if (!('drm' in result)) { + // The host never launched mpv, so let the next stream try. Not after a copy-protected + // video's: the page's next stream would be one of its pieces, or an ad's. + tab.mpvAutoOpened = false; + Tabs.saveTabState(tab); + } + }).catch((e) => { + console.error('Handing the stream to mpv failed', e); + if (isHandOffPage(tab, page)) { + setMpvError(tab, {ok: false, error: String(e)}); + } + }); +} + +/** + * MPV autoplay's next page (tab.mpvNextEpisodeUntil): the stream to send, picked as + * openMpvWithSources picks, once the lengths of what the page loaded are read. Its first + * stream went at once before, and here this extension started the page's player itself + * (startPagePlayers): a hoster's player may load an ad's stream first, which went to mpv under + * the episode's title, and the episode's own, detected after, was dropped (an anime site, season 1 + * to 2, his report 2026-10-13: mpv showed the title on a black picture). Each detection + * starts the pick again. One known to run under StreamPick's SHORT_S (an ad, a trailer, a + * preview) waits for a longer one until MpvNextPickGraceMs before the minute is up, and goes + * then as the best there is. Only streams detected since the episode was asked for: a frame + * of the page before that is not always gone from the tab (its FRAME_REMOVED never came), + * and its episode, the longest, went to mpv again. + * @param {TabHolder} tab - The tab gone on to the next episode. + */ +export async function pickNextEpisodeForMpv(tab) { + const pick = {}; + tab.mpvNextPick = pick; + clearTimeout(tab.mpvNextPickTimer); + // Only while the episode is still waited for: another tab's stream that took the mpv window + // ends the wait (watchMpvFor), and a pick then sent this tab's page over it. + const open = () => tab.mpvNextEpisodeUntil > Date.now(); + if (!open()) { + return; + } + const page = tab.mpvPage; + const since = tab.mpvNextEpisodeUntil - MpvNextEpisodeWaitMs; + const fresh = () => tabSources(tab).filter((source) => !(source.time < since)); + await Lengths.settle(fresh, SourceLengthWaitMs); + if (tab.mpvNextPick !== pick || !open() || !isTabOpen(tab) || !isHandOffPage(tab, page) || tab.mpvAutoOpened || + !tab.isOn || !tab.isMpv || !Options.mpvMode) { + return; + } + const source = newestOfLongest(fresh()); + if (!source) { + return; + } + const length = Lengths.lengthOf(source.url); + const left = tab.mpvNextEpisodeUntil - Date.now(); + if (typeof length === 'number' && length > 0 && length < SHORT_S && left > MpvNextPickGraceMs) { + if (Logging) console.log('[MPV autoplay] a short stream, waiting for a longer one:', source.url, length); + tab.mpvNextPickTimer = setTimeout(() => { + if (tab.mpvNextPick === pick) pickNextEpisodeForMpv(tab); + }, left - MpvNextPickGraceMs); + return; + } + autoOpenInMpv(tab, source.url, source.headers); +} + +/** + * Every stream detected in a tab's frames, once each. + * @param {TabHolder} tab + * @return {Array} + */ +export function tabSources(tab) { + const seen = new Set(); + /** @type {Array} */ + const sources = []; + for (const frame of tab.getFrames()) { + for (const source of frame.getSources()) { + if (!seen.has(source.url)) { + seen.add(source.url); + sources.push(source); + } + } + } + return sources; +} + +// MPV autoplay (his wish, 2026-10-11): an episode handed to mpv that plays to its end (with +// "Autoplay next episode in mpv" on), or mpv's key or button for the next or the previous +// episode, goes on to that episode in the tab's page, and its stream goes into the same mpv +// window, so a series plays episode after episode without the browser. The host (7) follows +// mpv over a port kept open (MpvBackend.watch), which also keeps this background page from +// unloading (measured, docs/notes/mpv.md). Host 9 (same day, his wishes): mpv shows episode buttons and an autoplay switch +// (the mpv setup's faststream-episodes.lua), which this setting drives and the switch flips; +// an end waits Options.autoplayCountdown seconds, which the switch or a key can cut short, +// and Space stop. +// The tab may be closed while mpv plays (his wish, 2026-10-27): the next and the previous +// episode's links are read from its page at each hand-off (rememberEpisodeLinks), and an end +// or a key then opens the episode in a tab not shown (goToEpisodeInNewTab), which closes again +// once its stream went to mpv. +/** + * The episode links of the page a stream went to mpv from, and where its tab was. + * @typedef {{tabId: number, next: ?string, previous: ?string, seriesEnd: boolean, + * cookieStoreId: ?string}} EpisodeLinksOfTab + */ +/** + * @typedef {{tabId: number, url: string, qualities: ?Array<{url: string}>, alive: boolean, going: boolean, + * countdown: ?ReturnType, countdownAsks: number, + * stalled: ?ReturnType, + * links: ?EpisodeLinksOfTab, closedTab: ?{links: EpisodeLinksOfTab, windowId: number, windowClosing: boolean}, + * watcher: ?{notify: Function, hide: Function, state: Function, stop: Function}}} MpvWatch + */ +/** @type {?MpvWatch} */ +export let mpvWatch = null; + +// How long the next page has to bring its stream (MPV key's mode: TabHolder.mpvNextEpisodeUntil). +export const MpvNextEpisodeWaitMs = 60000; + +// How long before that minute is up a short stream goes as the best there is +// (pickNextEpisodeForMpv). +export const MpvNextPickGraceMs = 5000; + +// How long mpv shows a notice that is a sentence to read (host 11; before, every notice went +// after notify.lua's 2 s). The countdown's stays for the countdown; on and off for 2 s. +export const MpvNoticeSeconds = 6; + +/** + * The seconds between an episode's end and the next one (Options.autoplayCountdown). + * @return {number} 0 to 30. + */ +export function autoplayCountdownSeconds() { + const seconds = Number(Options.autoplayCountdown); + return Number.isFinite(seconds) ? Math.min(30, Math.max(0, Math.round(seconds))) : 5; +} + +/** + * Stops an end's countdown, if one runs. + * @param {MpvWatch} state + */ +export function cancelMpvCountdown(state) { + // A countdown still asking the page (startMpvCountdown) does not start either. + state.countdownAsks++; + if (state.countdown) { + clearTimeout(state.countdown); + state.countdown = null; + // Its notice stays for the whole countdown: a new stream, or autoplay switched off on + // the options page, would leave "next episode in 5 s" up with nothing coming. + state.watcher?.hide(); + } +} + +/** + * What mpv's autoplay switch shows: after the watch began, and when the setting changed. + * @param {?MpvWatch} state + */ +export function syncMpvAutoplay(state) { + if (!state?.alive) return; + if (!Options.mpvAutoplayNext) cancelMpvCountdown(state); + state.watcher?.state({autoplay: !!Options.mpvAutoplayNext}); +} + +/** + * Follows the mpv a stream of the tab just went to: the latest hand-off is the one an end or + * a key goes on from. Only a single mpv window (Mpv.singleInstance), which the host can + * follow; a window per stream has no pipe. + * @param {TabHolder} tab + * @param {string} url - The stream sent. + * @param {?Array<{url: string}>} [qualities] - The page's qualities of it that went along + * (isSentStream). + */ +export function watchMpvFor(tab, url, qualities = null) { + if (!tab || typeof tab.tabId !== 'number' || !isTabOpen(tab) || !Mpv.singleInstance) { + return; + } + // The next episode came: what was asked for it is done. + if (tab.continuationOptions?.mpvNext) { + tab.continuationOptions = null; + } + tab.mpvNextEpisodeUntil = 0; + // Another tab's stream took the window: the episode that tab still waits for no longer + // goes to mpv by itself, where it would replace this one. + const before = mpvWatch && mpvWatch.tabId !== tab.tabId ? Tabs.getTab(mpvWatch.tabId) : null; + if (before) { + before.mpvNextEpisodeUntil = 0; + if (before.continuationOptions?.mpvNext) { + before.continuationOptions = null; + } + // One autoplay opened for an episode that no longer goes to mpv. + closeReopenedTab(before); + } + if (mpvWatch?.alive) { + // A stream arrived: the next page's "no stream" alarm, and an end's countdown, are done. + if (mpvWatch.stalled) clearTimeout(mpvWatch.stalled); + mpvWatch.stalled = null; + cancelMpvCountdown(mpvWatch); + mpvWatch.tabId = tab.tabId; + mpvWatch.url = url; + mpvWatch.qualities = qualities; + // A tab that is open now is what an end goes on from. The links of the tab's page before + // are not this one's: closed before these are read, it has none (glm's review). + mpvWatch.closedTab = null; + mpvWatch.links = null; + rememberEpisodeLinks(mpvWatch, tab); + return; + } + /** @type {MpvWatch} */ + const state = {tabId: tab.tabId, url, qualities, alive: true, going: false, countdown: null, countdownAsks: 0, + stalled: null, links: null, closedTab: null, watcher: null}; + mpvWatch = state; + state.watcher = Mpv.watch((event) => onMpvWatchEvent(state, event)); + rememberEpisodeLinks(state, tab); +} + +/** How long rememberEpisodeLinks waits to read the links again when the page had none yet. */ +export const EpisodeLinksAgainMs = 10000; + +/** + * Reads the next and the previous episode's links of the page whose stream just went to mpv, + * for an end or a key to go on with once its tab is closed (goToEpisodeInNewTab), through + * EPISODE_LINKS, which clicks nothing. A page that lists its episodes only a while after its + * stream (a list it fetches) is read once more, also in a tab autoplay opened for the episode + * itself (TabHolder.mpvReopened), which closes once read. + * @param {MpvWatch} state + * @param {TabHolder} tab + * @return {Promise} + */ +export async function rememberEpisodeLinks(state, tab) { + const page = tab.mpvPage; + const stillThePage = () => state.alive && mpvWatch === state && state.tabId === tab.tabId && isTabOpen(tab) && + tab.mpvPage === page; + let links = await readEpisodeLinks(tab); + if (!stillThePage()) return; + state.links = links; + if (!links.next && !links.seriesEnd) { + await new Promise((resolve) => setTimeout(resolve, EpisodeLinksAgainMs)); + if (!stillThePage()) return; + links = await readEpisodeLinks(tab); + if (!stillThePage()) return; + state.links = links; + } + if (tab.mpvReopened) { + closeReopenedTab(tab); + } +} + +/** + * The page's episode links (EPISODE_LINKS), from its top frame only: the background opens one + * later as a tab of its own, and a frame's (a hoster's, an ad's) went there in the tab's + * container (glm's review, 2026-10-27), where goToEpisode, with the tab open, has such a frame + * go on only within itself. A page that does not answer within MpvPagePollMs cannot tell. + * @param {TabHolder} tab + * @return {Promise} + */ +export async function readEpisodeLinks(tab) { + /** @type {EpisodeLinksOfTab} */ + const links = {tabId: tab.tabId, next: null, previous: null, seriesEnd: false, cookieStoreId: null}; + const answer = await askFrame(tab.tabId, {type: MessageTypes.EPISODE_LINKS}, {frameId: 0}, 'episode_links', + MpvPagePollMs); + const address = (/** @type {*} */ value) => typeof value === 'string' && /^https?:\/\//i.test(value) ? value : null; + if (answer && typeof answer === 'object' && !answer.error) { + links.next = answer.hasNext === true ? address(answer.next) : null; + links.seriesEnd = answer.hasNext !== true && answer.seriesEnd === true; + links.previous = address(answer.previous); + } + // Its container, which the episode's tab opens in: the same cookies, the same login. The + // default and the private store go by the window (as the DOWNLOAD message's tab does). + const store = await chrome.tabs.get(tab.tabId).then((info) => info?.cookieStoreId || null, () => null); + links.cookieStoreId = store && store !== 'firefox-default' && store !== 'firefox-private' ? store : null; + return links; +} + +/** + * The tab mpv's stream came from is closed (tabs.onRemoved): the episode links its page had + * stay with the watch, for an end or a key to go on in a new tab (goToEpisodeInNewTab). + * @param {number} tabId + * @param {{windowId: number, isWindowClosing: boolean}} removed + */ +export function noteMpvTabClosed(tabId, removed) { + const state = mpvWatch; + if (!state?.alive || state.tabId !== tabId || state.links?.tabId !== tabId) return; + state.closedTab = {links: state.links, windowId: removed.windowId, windowClosing: !!removed.isWindowClosing}; + if (Logging) console.log('[MPV autoplay] its tab was closed; the episodes go on in a new one:', state.links); +} + +/** + * What the host says about the watched mpv. + * @param {MpvWatch} state - The watch (mpvWatch). + * @param {import('./MpvBackend.mjs').MpvWatchEvent} event - watching, path, ended, request, + * decoder or closed. + */ +export function onMpvWatchEvent(state, event) { + if (event.type === 'closed') { + state.alive = false; + cancelMpvCountdown(state); + if (state.stalled) clearTimeout(state.stalled); + state.stalled = null; + if (mpvWatch === state) mpvWatch = null; + // mpv is gone (both watches' reasons for it): the page's next play is the user's, not + // the player repeating itself. Within MpvPlayRepeatMs it only paused the page, and + // closing mpv quickly left a dead click. A helper port that went while mpv plays on + // keeps the window: a repeat would load the episode again from where the page is. + const tab = event.reason === 'mpv closed' || event.reason === 'no mpv' ? Tabs.getTab(state.tabId) : null; + if (tab?.mpvLastPlaySend) { + tab.mpvLastPlaySend = null; + Tabs.saveTabState(tab); + } + // A tab autoplay opened for an episode mpv no longer waits for. + if (tab) closeReopenedTab(tab); + if (Logging) console.log('[MPV autoplay] the watch ended:', event.reason); + return; + } + if (event.type === 'watching') { + syncMpvAutoplay(state); + } else if (event.type === 'decoder') { + // What the toolbar's tooltip names, for the tab whose stream mpv plays (followMpvDecoder). + const tab = Tabs.getTab(state.tabId); + if (tab) setMpvDecoder(tab, event.decoder); + } else if (event.type === 'ended') { + if (Options.mpvAutoplayNext && isSentStream(event.path, state.url, state.qualities)) { + startMpvCountdown(state); + } + } else if (event.type === 'request' && (event.action === 'next' || event.action === 'previous')) { + // A key or a button: now, whatever the countdown says. + cancelMpvCountdown(state); + goToEpisode(state, event.action); + } else if (event.type === 'request' && event.action === 'autoplay') { + toggleMpvAutoplay(state); + } else if (event.type === 'request' && event.action === 'pause') { + // Space at an end (his wish, 2026-10-17: the easiest key): this episode's countdown stops, + // and autoplay stays on for the next end, which the switch would have turned off. mpv + // stays paused on the last frame. Space during playback finds no countdown to stop. + const counting = !!state.countdown; + cancelMpvCountdown(state); + if (counting) { + state.watcher?.notify(chrome.i18n.getMessage('mpv_autoplay_title') || 'Autoplay', + chrome.i18n.getMessage('mpv_autoplay_stopped') || 'stopped (n: next episode)'); + } + } +} + +/** How long startMpvCountdown waits for each frame of the page to answer. */ +export const MpvPagePollMs = 1000; + +/** + * How long goToEpisode waits for a frame to answer its next/previous request (the content + * script answers at once, after its click): a frame that never answered kept state.going + * set, and every later key, button and autoplay of the session returned at it (glm's + * review, 2026-10-12). + */ +export const MpvPageNavigateMs = 5000; + +/** + * What the page of the tab mpv's stream came from says about its next episode, asking its + * frames top first as goToEpisode does, through PLAYLIST_POLL, which clicks nothing: + * 'series_end' when the first frame that knows says the series ends here (no other frame is + * asked: a hoster's links are other videos), 'next' when it has a next episode, null when no + * frame can tell. + * @param {TabHolder} tab + * @return {Promise} + */ +export async function nextEpisodeInPage(tab) { + const frameIds = tab.frames ? Array.from(tab.frames.keys()).sort((a, b) => a - b) : []; + for (const frameId of frameIds) { + // A frame that never answers costs a second, not the countdown (glm's review): no + // answer is "cannot tell". + const answer = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(undefined), MpvPagePollMs); + chrome.tabs.sendMessage(tab.tabId, {type: MessageTypes.PLAYLIST_POLL}, {frameId}, (response) => { + void chrome.runtime.lastError; + clearTimeout(timer); + resolve(response); + }); + }); + if (answer && typeof answer === 'object') { + if (answer.next === true) return 'next'; + if (answer.seriesEnd === true) return 'series_end'; + } + } + return null; +} + +/** + * An episode ended in mpv with autoplay on: the next one after the countdown, which mpv + * shows. Space stops it, the autoplay switch too (and autoplay with it); the next-episode + * key or button skips it. + * + * The page is asked first (2026-10-12, the mpv setup's TODO): at the series' last episode + * mpv said "next episode in 5 s", and only when the countdown ran out that autoplay stops. + * Now it says the series ended at once, and counts down nothing. A page that cannot tell + * counts down as before (goToEpisode then says what it finds). + * @param {MpvWatch} state + * @return {Promise} + */ +export async function startMpvCountdown(state) { + if (state.countdown) return; + const seconds = autoplayCountdownSeconds(); + if (seconds === 0) { + goToEpisode(state, 'next'); + return; + } + // A key, the switch, mpv closing or another end while the page is asked wins over this one. + const ask = ++state.countdownAsks; + const tab = Tabs.getTab(state.tabId); + // A closed tab's page said it when its stream went to mpv (rememberEpisodeLinks). + const page = tab && tab.isOn && tab.isMpv ? await nextEpisodeInPage(tab) : + !tab && state.closedTab?.links.seriesEnd ? 'series_end' : null; + if (ask !== state.countdownAsks || state.countdown || !state.alive || !Options.mpvAutoplayNext) return; + if (page === 'series_end') { + notifySeriesEnd(state); + return; + } + state.watcher?.notify(chrome.i18n.getMessage('mpv_autoplay_title') || 'Autoplay', + chrome.i18n.getMessage('mpv_autoplay_countdown', [String(seconds)]) || + `next episode in ${seconds} s (n: now, Space: stop)`, seconds); + state.countdown = setTimeout(() => { + state.countdown = null; + if (state.alive && Options.mpvAutoplayNext) goToEpisode(state, 'next'); + }, seconds * 1000); +} + +/** + * mpv's autoplay switch: flips the setting, which mpv then shows. + * @param {MpvWatch} state + * @return {Promise} + */ +export function toggleMpvAutoplay(state) { + // One after the other: two quick presses read the same setting before either was saved, + // and the second was lost. + mpvAutoplayToggles = mpvAutoplayToggles.then(async () => { + const on = !Options.mpvAutoplayNext; + try { + await saveOption('mpvAutoplayNext', on); + } catch (e) { + console.error('Saving MPV autoplay failed', e); + return; + } + syncMpvAutoplay(state); + state.watcher?.notify(chrome.i18n.getMessage('mpv_autoplay_title') || 'Autoplay', + chrome.i18n.getMessage(on ? 'mpv_autoplay_on' : 'mpv_autoplay_off') || (on ? 'on' : 'off')); + }); + return mpvAutoplayToggles; +} + +/** The switch presses not done yet (toggleMpvAutoplay). */ +export let mpvAutoplayToggles = Promise.resolve(); + +/** + * Goes on to the next (or the previous) episode in the page of the tab mpv's stream came + * from: the page's own link, as the in-page player's next button does it, top frame first. + * The next page's player is started if it waits for a click (startPagePlayers), and its + * stream goes to mpv by itself, into the same window. mpv says what happens. + * @param {MpvWatch} state - The watch (mpvWatch). + * @param {'next'|'previous'} direction + */ +export async function goToEpisode(state, direction) { + const title = chrome.i18n.getMessage(direction === 'next' ? 'player_next_video_label' : 'player_previous_video_label') || + (direction === 'next' ? 'Next video' : 'Previous video'); + const tab = Tabs.getTab(state.tabId); + if (!tab && state.closedTab) { + return goToEpisodeInNewTab(state, direction, title); + } + if (!tab || !tab.isOn || !tab.isMpv) { + notifyEpisodeUnavailable(state, title); + return; + } + // An end and a key at the same moment would click the page's link twice, a skipped episode. + if (state.going) { + return; + } + state.going = true; + const frameIds = Array.from(tab.frames.keys()).sort((a, b) => a - b); + let clicked = false; + let seriesEnd = false; + try { + for (const frameId of frameIds) { + const answer = await new Promise((resolve) => { + const timer = setTimeout(() => resolve('timeout'), MpvPageNavigateMs); + chrome.tabs.sendMessage(tab.tabId, {type: MessageTypes.PLAYLIST_NAVIGATION, direction}, {frameId}, (response) => { + void chrome.runtime.lastError; + clearTimeout(timer); + resolve(response); + }); + }); + // No other frame is asked: this one may still click, and a hoster's link after it + // would be a second episode. + if (answer === 'timeout') break; + if (answer === 'clicked') { + clicked = true; + break; + } + // The page is the series' last episode (content.js isSeriesEnd): no other frame (a + // hoster's, whose links are other videos) is asked. + if (answer === 'series_end') { + seriesEnd = true; + break; + } + } + } finally { + state.going = false; + } + // Another tab's stream took mpv while the page was asked (watchMpvFor moves the watch to + // it): what this page did is no longer what mpv waits for. The old tab got the next + // episode's wait all the same, and a minute later mpv said "no stream" over the other + // tab's video (glm's second look, 2026-10-13). + if (state.tabId !== tab.tabId || !state.alive) { + return; + } + if (seriesEnd) { + notifySeriesEnd(state); + return; + } + if (!clicked) { + state.watcher?.notify(title, chrome.i18n.getMessage('mpv_episode_none') || 'none found on the page', + MpvNoticeSeconds); + noteProblem(tab.tabId, {url: tab.url, kind: ProblemKinds.NO_NEXT, mode: 'mpv'}).catch((e) => console.error(e)); + return; + } + if (Logging) console.log('[MPV autoplay] went on:', direction, tab.tabId); + awaitEpisodeStream(state, tab, title); +} + +/** + * mpv's notice that the episode cannot be gone to: its tab is closed (and its page had no + * link to it, or its window went), or not in MPV mode. + * @param {MpvWatch} state + * @param {string} title + */ +export function notifyEpisodeUnavailable(state, title) { + state.watcher?.notify(title, chrome.i18n.getMessage('mpv_episode_unavailable') || + 'its tab is closed, or not in MPV mode', MpvNoticeSeconds); +} + +/** + * goToEpisode with the tab of mpv's stream closed (his wish, 2026-10-27): the episode its page + * linked to (rememberEpisodeLinks) opens in a new tab that is not shown, in the window and the + * container the closed one was in, and goes on as the tab did (awaitEpisodeStream). Measured + * in Firefox 158: such a tab loads the hoster, its player is started, and its stream is + * asked for, while Firefox holds the page's own play() until the tab is shown. Once the stream + * went to mpv, the tab closes again (rememberEpisodeLinks). Not when the closed tab's window + * went with it, nor for a page whose next was a button, not a link. + * @param {MpvWatch} state + * @param {'next'|'previous'} direction + * @param {string} title - mpv's notice's title. + * @return {Promise} + */ +export async function goToEpisodeInNewTab(state, direction, title) { + const closed = state.closedTab; + if (!closed) return; + const url = direction === 'next' ? closed.links.next : closed.links.previous; + if (!url) { + if (direction === 'next' && closed.links.seriesEnd) { + notifySeriesEnd(state); + } else { + notifyEpisodeUnavailable(state, title); + } + return; + } + // Its window went with it; or MPV mode was switched off since (leaveMpvWhenOff), which a + // tab still open shows by its isMpv (glm's review). + if (closed.windowClosing || !Options.mpvMode) { + notifyEpisodeUnavailable(state, title); + return; + } + // An end and a key at the same moment would open the episode twice. + if (state.going) { + return; + } + state.going = true; + /** @type {?chrome.tabs.Tab} */ + let created = null; + try { + created = await chrome.tabs.create({url, active: false, windowId: closed.windowId, + ...(closed.links.cookieStoreId ? {cookieStoreId: closed.links.cookieStoreId} : {})}); + } catch (e) { + console.error('Opening the next episode\'s tab failed', e); + } finally { + state.going = false; + } + if (!created || typeof created.id !== 'number') { + notifyEpisodeUnavailable(state, title); + return; + } + // mpv closed, or another tab's stream took it, while the tab was made. + if (!state.alive || mpvWatch !== state || state.closedTab !== closed) { + chrome.tabs.remove(created.id).catch(() => {}); + return; + } + state.closedTab = null; + state.links = null; + state.tabId = created.id; + // MPV, as the closed tab was: the lists by address decide nothing for it (tabs.onUpdated + // keeps a tab that is on already). Matched by the MPV allowlist already, where it names the + // address: its own start would send the page's first stream, an ad's maybe, beside the + // episode's pick (glm's review). + const tab = Tabs.getTabOrCreate(created.id); + tab.isOn = true; + tab.isMpv = true; + tab.mpvOnPlay = false; + tab.mpvMatched = MpvAllowlist.matches(url); + tab.mpvReopened = true; + BackgroundUtils.updateTabIcon(tab); + if (Logging) console.log('[MPV autoplay] went on in a new tab:', direction, created.id, url); + awaitEpisodeStream(state, tab, title); + // Its frames may have come before the continuation did (FRAME_ADDED starts this too). + startPagePlayers(tab); +} + +/** + * The tab went on to the episode: its stream goes to mpv by itself (onSourceRecieved, + * pickNextEpisodeForMpv) for a minute, its page's player is started if it waits for a click + * (startPagePlayers), and mpv says what happens. + * @param {MpvWatch} state + * @param {TabHolder} tab + * @param {string} title - mpv's notice's title. + */ +export function awaitEpisodeStream(state, tab, title) { + tab.continuationOptions = {autoPlay: true, mpvNext: true}; + tab.mpvNextEpisodeUntil = Date.now() + MpvNextEpisodeWaitMs; + // Not for a tab closed meanwhile: its state went back into storage (isTabOpen; glm's review). + if (isTabOpen(tab)) Tabs.saveTabState(tab); + state.watcher?.notify(title, chrome.i18n.getMessage('mpv_episode_loading') || 'loading in mpv', + MpvNoticeSeconds); + // The page gone to had its minute and brought no stream (a locked episode, a dead hoster, + // a login wall): mpv says so, where it otherwise sat at the old episode's end in silence. + if (state.stalled) clearTimeout(state.stalled); + state.stalled = setTimeout(() => { + state.stalled = null; + if (!state.alive || mpvWatch !== state || state.tabId !== tab.tabId) return; + noteProblem(tab.tabId, {url: tab.url, kind: ProblemKinds.NO_STREAM, mode: 'mpv'}).catch((e) => console.error(e)); + state.watcher?.notify(title, chrome.i18n.getMessage('mpv_episode_nostream') || + 'the page brought no stream within a minute; open it in the browser to see why', MpvNoticeSeconds); + }, MpvNextEpisodeWaitMs); +} + +/** + * Whether the host's answer to a hand-off still belongs to the tab's page: the tab is open + * and shows the page the stream was sent from (tab.mpvPage). A fresh mpv answers seconds + * after the send. An answer for the page before paused the next page's video with nothing + * sent for it, showed that page's failure or decoder on the button, and a failure opened + * the allowlist's hand-off again, which then sent the next page's second stream as well. + * @param {TabHolder} tab - The tab the stream was sent from. + * @param {number} page - Its tab.mpvPage when the stream was sent. + * @return {boolean} + */ +export function isHandOffPage(tab, page) { + return isTabOpen(tab) && tab.mpvPage === page; +} + +/** + * Shows a failed MPV-mode hand-off on the tab's toolbar button ("!", and the reason in + * its tooltip), or clears it after one that worked. Without it the page simply played + * on in the browser, and nothing said why. An outdated host is kept too, for the tooltip + * alone (no "!" since the thin helper, 2026-10-13: an older one still plays the stream): + * it says to install the host again, as the copy a PC runs is not updated by an extension + * update or a `git pull` (MpvBackend's RequiredHostVersion). + * @param {TabHolder} tab - The tab the stream was sent from. + * @param {{ok: boolean, error?: string, noHost?: boolean, hostOutdated?: boolean}} result - + * The host's answer. + */ +export function setMpvError(tab, result) { + if (!isTabOpen(tab)) { + return; + } + /** @type {?string} */ + let error = null; + if (!result.ok) { + error = result.noHost || !result.error ? + chrome.i18n.getMessage('player_mpv_no_host') || 'the FastStream mpv helper did not answer - is it installed?' : + result.error; + } + const hostOutdated = result.hostOutdated === true; + if (tab.mpvError !== error || tab.mpvHostOutdated !== hostOutdated) { + tab.mpvError = error; + tab.mpvHostOutdated = hostOutdated; + BackgroundUtils.updateTabIcon(tab); + Tabs.saveTabState(tab); + } +} + +// How long the host may wait, after a hand-off, for mpv to start decoding: a slow stream +// takes seconds to open, and until then mpv has no decoder to name. +export const MpvDecoderWaitMs = 20000; + +/** + * After a hand-off that worked, asks mpv which video decoder it plays the stream with, + * for the toolbar button's tooltip: the hardware API, or a hint that mpv decodes in + * software (with what to add to mpv.conf). Read-only: FastStream never overrides mpv.conf. + * Only an mpv the host started for single-instance use has the pipe to ask over. + * @param {TabHolder} tab - The tab the stream was sent from. + * @param {{ok: boolean}} result - The hand-off's answer. + */ +export function followMpvDecoder(tab, result) { + const query = {}; + tab.mpvDecoderQuery = query; + setMpvDecoder(tab, null); + if (!result.ok || !Mpv.singleInstance) { + return; + } + // The thin helper's relay, which the watch holds open, tells the decoder as mpv loads it + // (onMpvWatchEvent). Asked here, each second until mpv had one meant a new helper process + // (cmd and node) every second, up to MpvDecoderWaitMs (review of 2026-10-14). + if (Mpv.mayUse(Capabilities.RELAY)) { + return; + } + Mpv.decoderStatus(MpvDecoderWaitMs).then((status) => { + // A later hand-off (or a new page) asks again: this answer is about an older stream. + if (tab.mpvDecoderQuery !== query) { + return; + } + setMpvDecoder(tab, status.ok && status.running ? status.decoder || null : null); + }).catch((e) => console.warn('Asking mpv for its decoder failed', e)); +} + +/** + * @param {TabHolder} tab + * @param {?import('./MpvBackend.mjs').MpvDecoder} decoder + */ +export function setMpvDecoder(tab, decoder) { + // The answer takes up to MpvDecoderWaitMs, and the tab is often closed by then: the + // stream plays in mpv now. + if (!isTabOpen(tab) || JSON.stringify(tab.mpvDecoder ?? null) === JSON.stringify(decoder)) { + return; + } + tab.mpvDecoder = decoder; + BackgroundUtils.updateTabIcon(tab); + Tabs.saveTabState(tab); +} + +/** + * Hands a video the user started to mpv, then pauses the page. + * + * The same video again within MpvPlayRepeatMs is the player repeating itself + * (play() called twice, or resuming after pauseTabMedia), so it only pauses + * the page again. openStream gets no tab: its per-page dedupe would swallow a + * later, deliberate play of a video already sent (after closing mpv). + * + * @param {TabHolder} tab - The tab the video is in. + * @param {{url: string, headers: *}} source - Detected source: url and request headers. + * @param {?{time?: number, duration?: ?number}} [video] - The page's video as it played + * (content.js playedVideo): mpv starts where it was. It started at 0:00, and the page, + * paused at the user's place, kept it. + */ +export function sendPlayedToMpv(tab, source, video = null) { + // As autoOpenInMpv: a tab closed while its stream was looked for sends nothing. + if (!isTabOpen(tab)) { + return; + } + const now = Date.now(); + const last = tab.mpvLastPlaySend; + if (last && last.url === source.url && now - last.time < MpvPlayRepeatMs) { + pauseTabMedia(tab.tabId); + return; + } + + tab.mpvLastPlaySend = {url: source.url, time: now}; + Tabs.saveTabState(tab); + const page = tab.mpvPage; + // Only a video of a known length (not a live one), and past its first seconds: a play + // from the start starts at the start. + const time = video?.time; + const startTime = Number.isFinite(video?.duration) && typeof time === 'number' && time > 5 ? time : undefined; + tabTitle(tab.tabId).then((title) => + openInMpv(tab.tabId, source.url, null, source.headers, resolveMpvContentType(null, tab.url), tab.url, title, + {startTime})).then((result) => { + if (Logging) console.log('[MPV] user play result:', source.url, JSON.stringify(result)); + if (!isHandOffPage(tab, page)) { + return; + } + setMpvError(tab, result); + followMpvDecoder(tab, result); + if (result.ok) { + pauseAfterHandOff(tab, result); + } else if (tab.mpvLastPlaySend && tab.mpvLastPlaySend.url === source.url) { + // The host never launched mpv, so let the next play try again. + tab.mpvLastPlaySend = null; + Tabs.saveTabState(tab); + } + }).catch((e) => { + console.error('Handing the played video to mpv failed', e); + if (isHandOffPage(tab, page)) { + setMpvError(tab, {ok: false, error: String(e)}); + } + }); +} + +/** + * Sends the newest of the longest sources tracked on the tab to mpv via the + * native messaging host. + * + * Only one source is sent. mpv opens a window per invocation, and a page + * routinely exposes several sources (ads, previews, one per quality), so + * sending them all would bury the user in mpv windows. An ad or a preview + * runs for seconds, the video for minutes, and of those that tie the newest + * is the one the page just started playing; the rest stay tracked, so the + * player's "send to mpv" button can still reach them. + * + * @param {TabHolder} tab - The tab whose tracked sources should open in mpv. + * @return {boolean} True when a source was handed to mpv. + */ +export function openMpvWithSources(tab) { + if (!Options.mpvMode) { + return false; + } + + // Entering MPV mode is an explicit start, so forget what was already sent + // for this tab. Without this the dedupe in openStream silently swallows a + // re-entry that targets the same URL (toolbar cycled MPV -> Off -> On -> MPV). + tab.mpvSentUrls.clear(); + + const source = newestOfLongest(tabSources(tab)); + if (!source) { + return false; + } + + tab.mpvAutoOpened = true; + Tabs.saveTabState(tab); + const page = tab.mpvPage; + tabTitle(tab.tabId).then((title) => + openInMpv(tab.tabId, source.url, tab, source.headers, resolveMpvContentType(null, tab.url), tab.url, title)).then((result) => { + if (Logging) console.log('[MPV] openStream result:', source.url, JSON.stringify(result)); + if (!isHandOffPage(tab, page)) { + return; + } + setMpvError(tab, result); + followMpvDecoder(tab, result); + if (result.ok) { + pauseAfterHandOff(tab, result); + } else { + // The host never launched mpv, so let the next detected stream try. + tab.mpvAutoOpened = false; + Tabs.saveTabState(tab); + } + }).catch((e) => { + console.error('Handing the stream to mpv failed', e); + if (isHandOffPage(tab, page)) { + setMpvError(tab, {ok: false, error: String(e)}); + } + }); + return true; +} + +/** + * Hands a stream to mpv, unless the page's request for it went through Firefox VPN: mpv + * runs outside Firefox, asks from the user's own address, and a site that ties its stream + * to the VPN's refuses it ("could not open the stream", VOE, 2026-10-06). Then mpv is not + * started, and the answer says why, where a failed hand-off shows (the toolbar button's + * "!" and tooltip, the player's status line). After the tab, arguments as + * MpvBackend.openStream. + * @param {number|undefined} tabId - The tab the stream is from: whether it is a private + * window decides which of the VPN's hosts count (VpnProxyMirror.throughBrowserVpn). + * @param {string} url - The stream. + * @param {...*} rest - openStream's other arguments. + * @return {Promise} openStream's answer, or {ok: false, vpn: true, error}, or + * {ok: false, drm: true, error}. + */ +export async function openInMpv(tabId, url, ...rest) { + let incognito; + if (tabId !== undefined && tabId >= 0) { + try { + incognito = !!(await chrome.tabs.get(tabId))?.incognito; + } catch (e) { + // The tab went: both kinds of window count. + } + } + // The stream the page asked for: Firefox VPN is known to carry it by its host + // (VpnProxyMirror.noteSource). A quality file taken in its place may be on a host that check + // never saw - a hoster's get_file link, which redirects to its CDN - and it went to mpv on + // that file's host alone (this review, 2026-10-13). + const detected = url; + // The page's player offers this file in other qualities too (page-qualities.js): mpv gets + // the one the user's quality setting asks for, and the list. MPV mode sent the page's + // default, the lowest on a film site's hoster. Not for the player's button, which sends the + // one it plays and its own list (extras.qualities). + if (tabId !== undefined && tabId >= 0 && !(rest[5] && 'qualities' in rest[5])) { + const choice = await pageQualityChoice(Tabs.getTab(tabId), url); + if (choice) { + if (Logging && choice.url !== url) console.log('[MPV] the page\'s best quality instead:', choice.url); + url = choice.url; + rest[5] = {...(rest[5] || {}), qualities: choice.list}; + } + } + // An HLS stream: the quality its master playlist offers that the setting asks for, and the + // list for mpv's quality menu (hlsQualityChoice). Not for the player's button either. + if (!(rest[5] && 'qualities' in rest[5])) { + const choice = await hlsQualityChoice(tabId !== undefined && tabId >= 0 ? Tabs.getTab(tabId) : null, url); + if (choice) { + if (Logging && choice.url !== url) console.log('[MPV] the HLS stream\'s best quality instead:', choice.url); + url = choice.url; + if (choice.list) rest[5] = {...(rest[5] || {}), qualities: choice.list}; + } + } + // Copy-protected (DRM, StreamLengths.isProtected): mpv has no key system either, and its + // window stayed black where the page's player plays the video. Its manifest's read tells, + // which started when it was detected, with the tab on; a master's tells for its variants. + // An HLS stream's is waited for above (hlsQualityChoice) when it gives the qualities; the + // player's button gives its own, a DASH stream none, and a stream detected while the tab + // was off was not read: waited for here, as long. + const tracked = tabId !== undefined && tabId >= 0 && Tabs.getTab(tabId) ? + tabSources(Tabs.getTab(tabId)).find((source) => source.url === detected) : null; + if (tracked && (tracked.mode === PlayerModes.ACCELERATED_HLS || tracked.mode === PlayerModes.ACCELERATED_DASH)) { + await Lengths.settle(() => [tracked], HlsQualitiesWaitMs); + } + if (Lengths.isProtected(detected) || Lengths.isProtected(url)) { + if (Logging) console.log('[MPV] not handed off, copy-protected:', url); + return { + ok: false, + drm: true, + error: chrome.i18n.getMessage('player_mpv_drm') || + 'This video is copy-protected (DRM), and mpv cannot play it: the site\'s own player keeps it.', + }; + } + if (VpnMirror.throughBrowserVpn(detected, incognito) || VpnMirror.throughBrowserVpn(url, incognito)) { + if (Logging) console.log('[MPV] not handed off, Firefox VPN carries it:', url); + return Promise.resolve({ + ok: false, + vpn: true, + error: chrome.i18n.getMessage('player_mpv_vpn') || + 'Firefox VPN carries this video, and mpv cannot use it. Turn Firefox VPN off for this site and reload the page.', + }); + } + // The page's subtitles go with its stream, as the in-page player shows them: MPV mode sent + // the stream alone. The player's button sends the tracks it shows (subtitles given, none + // too), which stay its choice; a stream already sent asks nothing (openStream). + const tabHolder = rest[0]; + const extras = rest[5]; + if (tabId !== undefined && tabId >= 0 && !(extras && 'subtitles' in extras) && + !tabHolder?.mpvSentUrls?.has(url)) { + const tab = Tabs.getTab(tabId); + const subtitles = tab ? await pageSubtitlesForMpv(tab).catch((e) => { + console.error('Reading the subtitles of the page for mpv failed', e); + return []; + }) : []; + if (subtitles.length > 0) { + if (Logging) console.log('[MPV] subtitles of the page go along:', subtitles.map((s) => s.label)); + rest[5] = {...(extras || {}), subtitles}; + } + } + if (incognito) { + // A private window's stream: mpv keeps no resume position for it (the owner's pick, + // 2026-10-14; fs-private, MpvTarget.mjs). + rest[5] = {...(rest[5] || {}), private: true}; + } + const result = await Mpv.openStream(url, ...rest); + // MPV autoplay: the mpv it went to is followed (watchMpvFor). By the tab's id: the + // player's button and the MPV key's play pass no tab (openStream's duplicate check). + if (result && result.ok && tabId !== undefined && tabId >= 0) { + watchMpvFor(Tabs.getTab(tabId), url, rest[5]?.qualities || null); + } + return result; +} diff --git a/chrome/background/background.mjs b/chrome/background/background.mjs index 7c7f0638..d7c7f965 100644 --- a/chrome/background/background.mjs +++ b/chrome/background/background.mjs @@ -1,7 +1,7 @@ // @ts-check import {PlayerModes} from '../player/enums/PlayerModes.mjs'; import {PIECE_LENGTH, STILLS_LENGTH, StreamLength} from '../player/utils/StreamLength.mjs'; -import {SHORT_S, StreamPick} from '../player/utils/StreamPick.mjs'; +import {StreamPick} from '../player/utils/StreamPick.mjs'; import {guessMediaInfo} from '../player/utils/MediaTitle.mjs'; import {URLUtils} from '../player/utils/URLUtils.mjs'; import {Utils} from '../player/utils/Utils.mjs'; @@ -17,7 +17,7 @@ import {sanitizeDownloadFilename} from './DownloadFilename.mjs'; import {fetchFreshLink} from './FreshLink.mjs'; import {KeyShortcut} from './KeyShortcut.mjs'; import {MessageTypes} from '../player/enums/MessageTypes.mjs'; -import {Capabilities, MpvBackend} from './MpvBackend.mjs'; +import {MpvBackend} from './MpvBackend.mjs'; import {MpvSubtitles} from './MpvSubtitles.mjs'; import {MultiRegexMatcher} from './MultiRegexMatcher.mjs'; import {RuleManager} from './NetRequestRuleManager.mjs'; @@ -26,22 +26,21 @@ import {classifyResponse, modeFromQuery, urlType} from './StreamDetection.mjs'; import {TabTracker} from './TabTracker.mjs'; import {UrlMatchList} from './UrlMatchList.mjs'; import {VpnProxyMirror} from './VpnProxyMirror.mjs'; +import {Mpv, MpvAllowlist, MpvNoticeSeconds, MpvPlayKnownAfterMs, MpvPlayPendingMs, autoOpenInMpv, inheritMpv, leaveMpvWhenOff, mpvWatch, noteMpvTabClosed, onToggleMpv, openInMpv, openMpvWithSources, pickNextEpisodeForMpv, resolveMpvContentType, sendPlayedToMpv, startMpv, stopMpv, syncMpvAutoplay, watchNextEpisodeArrives} from './MpvMode.mjs'; /** @typedef {typeof import('../player/options/defaults/DefaultOptions.mjs').DefaultOptions} PlayerOptions */ // Empty until the saved options are read (loadOptions; ensureOptions waits for them). -let Options = /** @type {PlayerOptions} */ ({}); +export let Options = /** @type {PlayerOptions} */ ({}); /** @type {{customSourcePatterns?: string}} What loadCustomPatterns last compiled. */ const OptionsCache = {}; // "Auto-enable URLs": prefix, `~` regex and `!` exclusion like the MPV allowlist, and // `-domain` to exclude a whole site, from the redirect of manifest links too. -const AutoEnableList = new UrlMatchList({domainEntriesExclude: true}); -const MpvAllowlist = new UrlMatchList(); -const Mpv = new MpvBackend(); +export const AutoEnableList = new UrlMatchList({domainEntriesExclude: true}); // FastStream's requests follow the page's through Firefox VPN (VpnProxyMirror.mjs). The // optional "proxy" permission, which the player asks for, lets them. -const VpnMirror = new VpnProxyMirror({ +export const VpnMirror = new VpnProxyMirror({ origin: chrome.runtime.getURL(''), getProxyApi: () => /** @type {any} */ (chrome).proxy || null, }); @@ -87,18 +86,6 @@ let LastManualMpvTime = 0; /** @type {?Promise} */ let LastManualMpvAnswer = null; -// The shortcut's MPV waits for the user to start a video (onUserPlay). A play -// whose stream is not detected yet takes the next one within MpvPlayPendingMs; -// the same video played again within MpvPlayRepeatMs is the player repeating -// itself, not a second request. -const MpvPlayPendingMs = 15000; -const MpvPlayRepeatMs = 10000; -// The allowlist's MPV, on a page Back gave back: how long a play whose stream is not -// known waits for it to be detected before the page's known stream goes -// (autoOpenKnownLater). 1 s (3 s until 2026-10-14): a play-triggered fetch lands within a -// few hundred ms, and every Back + play waited the whole time before mpv started. -const MpvPlayKnownAfterMs = 1000; - // How long a tab stays "armed" after content.js reports focus moving into // one of its player iframes (see the tabs.onCreated listener below). Short // on purpose: a popup/popunder script reacting to that same blur event fires @@ -112,27 +99,6 @@ const PopupGuardArmMs = 700; // without leaving the guard live long enough to catch unrelated activity. const PopupGuardChainMs = 400; -/** - * Resolves the anime/movie shader hint sent to mpv for a stream. - * - * Movie is the fallback on purpose: most sites on the MPV Allowlist are - * movie sites for this user, so only the anime ones need an `@anime` tag -- - * an untagged site, or none of this ever matching, still gets an explicit - * `movie` rather than nothing (which would leave it to mpv's own, less - * reliable folder-name heuristic). - * - * @param {?string} [explicit] - The player's manual per-video override - * (SaveManager.mjs's mpvContentType), or anything else to fall through. - * @param {string} [url] - Tab URL to check against the MPV Allowlist. - * @return {string} 'anime' or 'movie', never null/undefined. - */ -function resolveMpvContentType(explicit, url) { - if (explicit === 'anime' || explicit === 'movie') { - return explicit; - } - return MpvAllowlist.getContentType(url) || 'movie'; -} - // Resolves once options have been read from storage. Clicks and navigation // events can arrive before the initial load completes (fresh install, // add-on reload); awaiting this prevents them from acting on an empty @@ -142,7 +108,7 @@ function resolveMpvContentType(explicit, url) { // otherwise find no record of the user's choice and auto-start MPV again. /** @type {?Promise<*>} */ let OptionsLoadPromise = null; -function ensureOptions() { +export function ensureOptions() { if (!OptionsLoadPromise) { // Firefox's word for the system first: the toolbar states restored name the mpv helper's // steps for it (EnvUtils.isWindows). @@ -165,29 +131,29 @@ const ExtensionVersion = chrome.runtime.getManifest().version; // driver log, which CI keeps for a failed or retried spec: what the background detected, // opened and sent to mpv. getSelf() needs no permission; it answers within a tick, before // any page the tests open sends a request. -let Logging = false; +export let Logging = false; chrome.management.getSelf().then((self) => { Logging = self.installType === 'development'; }).catch(console.error); -const Tabs = new TabTracker(); +export const Tabs = new TabTracker(); const ruleManager = new RuleManager(); // How long each detected stream plays, so that the longest of a page's streams plays and // not the ad (StreamLength.longest). The reads go out from this background, in no tab: // the rule that gives them the page's headers matches on that. -const Lengths = new StreamLengths({ +export const Lengths = new StreamLengths({ setHeaders: (url, commands) => ruleManager.addHeaderRule(url, chrome.tabs.TAB_ID_NONE, commands), }); // The page's subtitles for mpv (MPV mode), read as Lengths reads: from this background, in // no tab, with the page's Origin and Referer. -const PageSubtitles = new MpvSubtitles({ +export const PageSubtitles = new MpvSubtitles({ setHeaders: (url, commands) => ruleManager.addHeaderRule(url, chrome.tabs.TAB_ID_NONE, commands), }); // How long a frame has to tell its elements before the stream goes to mpv without // them: content scripts share the page's event loop, and a busy page would hold mpv back. -const CaptionScrapeWaitMs = 1000; +export const CaptionScrapeWaitMs = 1000; // The longest a player waits for the lengths still being read. -const SourceLengthWaitMs = 2500; +export const SourceLengthWaitMs = 2500; // The longest a player waits for the page to say what its video played (getPlayedVideo). // The page answers within milliseconds unless its own scripts keep it busy; then the // longest decides, as before the question. @@ -205,7 +171,7 @@ const PageQualitiesWaitMs = 1000; // The longest a hand-off to mpv waits for an HLS master playlist to be read, for its // qualities (hlsQualityChoice), and for any manifest, for its copy protection (openInMpv); // the read starts when the stream is detected. -const HlsQualitiesWaitMs = 1500; +export const HlsQualitiesWaitMs = 1500; // How long a video's file the page told as it loaded (content.js on loadedmetadata, a // LOADED_MEDIA with live set) waits before it is taken in. The request for it, when there // was one, is the one to keep: its own headers (an Origin), not the page's stand-ins. It @@ -273,7 +239,7 @@ const HasPlayerTimeoutMs = 1000; * @param {TabHolder} tab - The tab to check. * @return {Promise} */ -async function tabHasPlayer(tab) { +export async function tabHasPlayer(tab) { if (hasOrOpeningPlayer(tab)) { return true; } @@ -288,86 +254,6 @@ async function tabHasPlayer(tab) { }); } -/** - * Puts a tab in MPV mode: FastStream on, streams handed to mpv. - * - * An active in-page player has to come down first - the same reload the plain - * Off/On toggle uses to undo one - because a FastStream overlay isn't - * something a message can cleanly retract. isMpv is set before the reload, so - * the freshly (re-)detected source is what onSourceRecieved forwards to mpv - * once the page reloads; nothing here has to redo that forwarding itself. - * - * With onPlay (the shortcut) nothing is handed off here or by the page's own - * stream requests: the tab waits for the user to start a video (onUserPlay). - * A video they started and are still watching counts as started now. - * - * What the button said of the last hand-off - its failure, an outdated host, mpv's - * decoder - goes: it is about a stream sent before, and after MPV off and on again it - * showed with nothing sent since. The next hand-off says it again. - * - * @param {TabHolder & TabExtras} tab - The tab to switch. - * @param {boolean} [onPlay] - Wait for the user to start a video. - * @return {Promise} - */ -async function startMpv(tab, onPlay = false) { - tab.isOn = true; - tab.isMpv = true; - tab.mpvOnPlay = onPlay; - // The user's own MPV (the MPV key, the toolbar's On -> MPV), not a list's start: it stays - // for the tab (his call, 2026-10-10). Still marked as the list's, it went off on the first - // page outside the lists of a site an auto-enable list had turned on (glm's review, - // 2026-10-13). - tab.regexMatched = false; - tab.mpvTurnedOff = false; - tab.mpvError = null; - tab.mpvHostOutdated = false; - tab.mpvDecoder = null; - tab.mpvDecoderQuery = null; - BackgroundUtils.updateTabIcon(tab); - - if (await tabHasPlayer(tab)) { - tab.resetForReload(); - chrome.tabs.reload(tab.tabId, () => BackgroundUtils.checkMessageError('reload', true)); // closed meanwhile: no unhandled error - } else if (onPlay) { - // A fresh start: the same video may go to mpv again. - tab.mpvSentUrls.clear(); - tab.mpvLastPlaySend = null; - tab.mpvPlayPendingUntil = 0; - tab.mpvPlayedVideo = null; - tab.mpvPlayChecking = null; - chrome.tabs.sendMessage(tab.tabId, { - type: MessageTypes.MPV_REPORT_PLAYING, - }, () => { - // A blank tab, or a page without the content script, is normal here. - BackgroundUtils.checkMessageError('mpv_report_playing'); - }); - } else { - // No player was ever requested for this page - hand off directly from - // whatever sources are already tracked. - openMpvWithSources(tab); - } -} - -/** - * Takes a tab out of MPV mode and turns FastStream off for it, tearing down - * any in-page player that is up or on its way. - * - * @param {TabHolder} tab - The tab to switch. - * @return {Promise} - */ -async function stopMpv(tab) { - tab.isOn = false; - tab.isMpv = false; - tab.mpvOnPlay = false; - userTurnedOff(tab); - BackgroundUtils.updateTabIcon(tab); - - if (await tabHasPlayer(tab)) { - tab.resetForReload(); - chrome.tabs.reload(tab.tabId, () => BackgroundUtils.checkMessageError('reload', true)); // closed meanwhile: no unhandled error - } -} - /** * The user turned the tab off, or MPV off (the toolbar, the MPV key, the player's key from * MPV). Off means off: nothing in the tab goes to mpv until they turn MPV on again. Each @@ -380,7 +266,7 @@ async function stopMpv(tab) { * went to mpv by itself. Another site is a fresh decision, as before. * @param {TabHolder} tab */ -function userTurnedOff(tab) { +export function userTurnedOff(tab) { tab.mpvTurnedOff = true; tab.mpvPlayPendingUntil = 0; tab.mpvPlayedVideo = null; @@ -410,31 +296,6 @@ function forgetOffsForNewEntries(before) { } } -/** - * Takes every tab out of MPV while MPV mode is off. That option is the switch for the - * whole mpv integration, and a tab keeps its mode until something changes it: one in MPV - * when the option was switched off, or put back so by a woken background, went on handing - * its streams to mpv - on a site on both the MPV Allowlist and the auto-enable list, page - * after page. Such a tab keeps FastStream on where the auto-enable list would turn it on, - * for the in-page player, and is off everywhere else, as after the toolbar's MPV -> Off. - * MPV opens no in-page player, so there is none to take down. - */ -function leaveMpvWhenOff() { - if (Options.mpvMode) { - return; - } - for (const tab of Tabs.tabs.values()) { - if (!tab.isMpv) { - continue; - } - tab.isMpv = false; - tab.mpvOnPlay = false; - tab.isOn = tab.isOn && !!tab.url && AutoEnableList.matches(tab.url); - BackgroundUtils.updateTabIcon(tab, true); - Tabs.saveTabState(tab); - } -} - /** * The toolbar button, and the toggle_player shortcut with playerKey. That command has * no default key: Firefox 159 binds Ctrl+Shift+F, its key until then, to its own Add-ons @@ -545,63 +406,6 @@ async function onClicked(tabobj, {playerKey = false} = {}) { // Not onClicked itself: the click's OnClickData would land in its second parameter. chrome.action.onClicked.addListener((tabobj) => onClicked(tabobj)); -/** - * The toggle_mpv shortcut (Alt+F unless the user rebinds it; Ctrl+Shift+U until - * 2026-10-04): MPV on or - * off for the tab, on any site. Unlike the toolbar cycle it does not need the - * site on the MPV Allowlist - pressing it is the deliberate choice the - * allowlist otherwise makes for the user. Off means FastStream off, the same - * as the toolbar's MPV -> Off step. From the in-page player it goes straight to - * MPV, as the player's key goes from MPV straight to the player (onClicked). - * - * Only a video the user starts goes to mpv (startMpv's onPlay, onUserPlay): - * on an arbitrary site the first stream a page loads is as likely a preview - * or a background clip as the video they came for. - * - * On a blank or new tab it arms MPV for the tab instead: nothing is playing - * yet, and the tab's mode survives navigation, so the first video of the next - * page opened there goes to mpv (new tab, Alt+F, paste a link). The - * toolbar opens the player page on a blank tab; that page has no video to send. - * On the player page itself it does nothing. - * - * Does nothing while MPV mode is off: that option is the switch for the whole - * mpv integration, and openMpvWithSources refuses without it. - * - * @param {chrome.tabs.Tab} tabobj - The tab that was active when the key was pressed. - */ -async function onToggleMpv(tabobj) { - await ensureOptions(); - - if (!Options.mpvMode) { - return; - } - - const hasPerms = await BackgroundUtils.checkPermissions(); - if (!hasPerms) { - chrome.tabs.create({ - url: chrome.runtime.getURL('perms.html'), - }).catch((e) => console.error('Opening the permissions page failed', e)); - return; - } - - const tab = Tabs.getTabOrCreate(/** @type {number} */ (tabobj.id)); - if (tabobj.url) { - tab.url = tabobj.url; - } - - if (tab.url && BackgroundUtils.isUrlPlayerUrl(tab.url)) { - return; - } - - if (tab.isOn && tab.isMpv) { - await stopMpv(tab); - } else { - await startMpv(tab, true); - } - - Tabs.saveTabState(tab); -} - chrome.commands.onCommand.addListener((command, tabobj) => { if (!tabobj) { return; @@ -769,29 +573,6 @@ chrome.tabs.onCreated.addListener(async (newTab) => { inheritMpv(openerTab, /** @type {number} */ (newTab.id)); }); -/** - * A tab a tab in MPV opened - a site's player in a pop-up tab, an episode middle-clicked - - * starts in MPV as well, the MPV key's way: only a video the user starts there goes to mpv - * (#337). MPV belongs to a tab, and the new one had none, so its video played in the - * browser. Never the allowlist's automatic first stream: most pop-ups are ads, and an - * autoplaying one would go to mpv. Firefox names the opener (openerTabId) for a tab, not - * for a pop-up window, which this cannot reach (measured 2026-10-04). - * @param {TabHolder} openerTab - The tab that opened it. - * @param {number} tabId - The new tab. - */ -function inheritMpv(openerTab, tabId) { - // isMpv only with isOn: every switch to Off clears both. - if (!Options.mpvMode || !openerTab.isMpv) { - return; - } - const tab = Tabs.getTabOrCreate(tabId); - tab.isOn = true; - tab.isMpv = true; - tab.mpvOnPlay = true; - BackgroundUtils.updateTabIcon(tab); - Tabs.saveTabState(tab); -} - chrome.tabs.onUpdated.addListener(async (tabid, changeInfo, tabobj) => { await ensureOptions(); @@ -2197,6 +1978,7 @@ async function attachPageQualities(tab, sources) { /** @typedef {import('./TabTracker.mjs').TabHolder} TabHolder */ /** @typedef {import('./TabTracker.mjs').TrackedSource} TrackedSource */ /** @typedef {import('./TabTracker.mjs').TrackedSubtitle} TrackedSubtitle */ +/** @typedef {import('./MpvMode.mjs').MpvWatch} MpvWatch */ /** * The fields this file sets on a TabHolder that TabTracker.mjs does not declare. @@ -2494,7 +2276,7 @@ const startPagePlayerUntil = new WeakMap(); * @param {{url: string, kind: string, mode: 'player'|'mpv'}} problem * @return {Promise} */ -async function noteProblem(tabId, problem) { +export async function noteProblem(tabId, problem) { let tab; try { tab = await chrome.tabs.get(tabId); @@ -2506,21 +2288,6 @@ async function noteProblem(tabId, problem) { await recordProblem(problem); } -/** - * Notes a next episode whose page brought no video within a minute (ProblemLog): the - * continuation is still there, as nothing took it (its sources, or mpv's stream). - * @param {TabHolder} tab - * @param {Continuation} continuation - * @param {'player'|'mpv'} mode - */ -function watchNextEpisodeArrives(tab, continuation, mode) { - const from = tab.url; - setTimeout(() => { - if (tab.continuationOptions !== continuation || !Tabs.tabs.has(tab.tabId) || tab.url === from) return; - noteProblem(tab.tabId, {url: tab.url, kind: ProblemKinds.NO_STREAM, mode}).catch((e) => console.error(e)); - }, MpvNextEpisodeWaitMs); -} - /** * Asks the next episode's page, in a tab FastStream went on in by itself (the player's * autoplay next), to start its own player, until its stream came. A hoster's player sat @@ -2532,7 +2299,7 @@ function watchNextEpisodeArrives(tab, continuation, mode) { * there, and that ends it; so do Off and MPV. * @param {TabHolder} tab */ -function startPagePlayers(tab) { +export function startPagePlayers(tab) { const continuation = tab.continuationOptions; if (!continuation || (tab.pagePlayerStarter && tab.pagePlayerContinuation === continuation)) return; // One still asking for an earlier next (two quick next keys): it stopped only at its own @@ -2639,7 +2406,7 @@ async function sendSources(frame) { /** * @param {FrameHolder} frame */ -async function scrapeCaptionsTags(frame) { +export async function scrapeCaptionsTags(frame) { const tabId = frame.tab.tabId; const frameId = frame.frameId; if (tabId < 0) return null; @@ -2653,7 +2420,7 @@ async function scrapeCaptionsTags(frame) { * @param {FrameHolder} frame - The frame they are in. * @param {*} subs - The frame's answer: [{data, source, label, language}], or nothing. */ -function takeScrapedCaptions(frame, subs) { +export function takeScrapedCaptions(frame, subs) { if (!Array.isArray(subs)) { return; } @@ -2668,30 +2435,6 @@ function takeScrapedCaptions(frame, subs) { }); } -/** - * A tab's subtitles, as SubRip for mpv: the subtitle files its page asked for and its - * elements, in every frame - what the in-page player would show from that page. A - * frame that does not tell its elements within CaptionScrapeWaitMs keeps those it - * had. - * @param {TabHolder} tab - * @return {Promise>} - */ -async function pageSubtitlesForMpv(tab) { - const frames = [...tab.getFrames()]; - await Promise.all(frames.map(async (frame) => { - let timer; - const subs = await Promise.race([ - scrapeCaptionsTags(frame), - new Promise((resolve) => { - timer = setTimeout(() => resolve(null), CaptionScrapeWaitMs); - }), - ]); - clearTimeout(timer); - takeScrapedCaptions(frame, subs); - })); - return PageSubtitles.toSrt(frames.flatMap((frame) => frame.getSubtitles())); -} - /** * What the page's video that a player replaced played (content.js replacedVideo), asked * of the frame the player's iframe is in. @@ -2729,7 +2472,7 @@ async function getPlayedVideo(player) { * @param {number} [waitMs] - How long an answer is waited for. * @return {Promise<*>} */ -function askFrame(tabId, message, target, label, waitMs = FrameAnswerWaitMs) { +export function askFrame(tabId, message, target, label, waitMs = FrameAnswerWaitMs) { return new Promise((resolve) => { const timer = setTimeout(() => resolve(undefined), waitMs); chrome.tabs.sendMessage(tabId, message, target, (answer) => { @@ -3125,7 +2868,7 @@ async function openPlayersWithSources(tab, foundBefore = Infinity) { * @param {number} tabId - Tab whose media should be paused. * @return {void} */ -function pauseTabMedia(tabId) { +export function pauseTabMedia(tabId) { if (!Options.mpvPausePage || typeof tabId !== 'number' || tabId < 0) { return; } @@ -3141,177 +2884,6 @@ function pauseTabMedia(tabId) { } } -/** - * Pauses the page after a hand-off the host says worked, and logs how raising mpv's window - * went (the host's focus, foreground and reused). Not when the user turned MPV off since - * the stream went (userTurnedOff): mpv got it all the same, but the page they went back to - * plays on. A fresh mpv answers seconds after the send. - * @param {TabHolder} tab - The tab the stream was sent from. - * @param {{focus?: string, foreground?: string, reused?: boolean}} result - The host's answer. - */ -function pauseAfterHandOff(tab, result) { - if (Logging) { - console.log('[MPV] mpv window: reused', result.reused === true, 'focus', result.focus, 'foreground', result.foreground); - } - if (tab.isOn && tab.isMpv) { - pauseTabMedia(tab.tabId); - } -} - -/** - * The allowlist's MPV: hands a page's stream to mpv, once per page - * (tab.mpvAutoOpened), then pauses the page. - * - * @param {TabHolder} tab - The tab the stream is in. - * @param {string} url - The stream. - * @param {*} headers - Its request headers, as detected. - */ -function autoOpenInMpv(tab, url, headers) { - // Reached after awaits (options, a stream's length): a tab closed meanwhile sends nothing. - if (!isTabOpen(tab)) { - return; - } - tab.mpvAutoOpened = true; - Tabs.saveTabState(tab); - const page = tab.mpvPage; - if (Logging) console.log('[MPV] forwarding detected stream to mpv:', url); - tabTitle(tab.tabId).then((title) => - openInMpv(tab.tabId, url, tab, headers, resolveMpvContentType(null, tab.url), tab.url, title)).then((result) => { - if (Logging) console.log('[MPV] forward result:', url, JSON.stringify(result)); - if (!isHandOffPage(tab, page)) { - return; - } - setMpvError(tab, result); - followMpvDecoder(tab, result); - if (result.ok) { - pauseAfterHandOff(tab, result); - } else if (!('drm' in result)) { - // The host never launched mpv, so let the next stream try. Not after a copy-protected - // video's: the page's next stream would be one of its pieces, or an ad's. - tab.mpvAutoOpened = false; - Tabs.saveTabState(tab); - } - }).catch((e) => { - console.error('Handing the stream to mpv failed', e); - if (isHandOffPage(tab, page)) { - setMpvError(tab, {ok: false, error: String(e)}); - } - }); -} - -/** - * MPV autoplay's next page (tab.mpvNextEpisodeUntil): the stream to send, picked as - * openMpvWithSources picks, once the lengths of what the page loaded are read. Its first - * stream went at once before, and here this extension started the page's player itself - * (startPagePlayers): a hoster's player may load an ad's stream first, which went to mpv under - * the episode's title, and the episode's own, detected after, was dropped (an anime site, season 1 - * to 2, his report 2026-10-13: mpv showed the title on a black picture). Each detection - * starts the pick again. One known to run under StreamPick's SHORT_S (an ad, a trailer, a - * preview) waits for a longer one until MpvNextPickGraceMs before the minute is up, and goes - * then as the best there is. Only streams detected since the episode was asked for: a frame - * of the page before that is not always gone from the tab (its FRAME_REMOVED never came), - * and its episode, the longest, went to mpv again. - * @param {TabHolder} tab - The tab gone on to the next episode. - */ -async function pickNextEpisodeForMpv(tab) { - const pick = {}; - tab.mpvNextPick = pick; - clearTimeout(tab.mpvNextPickTimer); - // Only while the episode is still waited for: another tab's stream that took the mpv window - // ends the wait (watchMpvFor), and a pick then sent this tab's page over it. - const open = () => tab.mpvNextEpisodeUntil > Date.now(); - if (!open()) { - return; - } - const page = tab.mpvPage; - const since = tab.mpvNextEpisodeUntil - MpvNextEpisodeWaitMs; - const fresh = () => tabSources(tab).filter((source) => !(source.time < since)); - await Lengths.settle(fresh, SourceLengthWaitMs); - if (tab.mpvNextPick !== pick || !open() || !isTabOpen(tab) || !isHandOffPage(tab, page) || tab.mpvAutoOpened || - !tab.isOn || !tab.isMpv || !Options.mpvMode) { - return; - } - const source = newestOfLongest(fresh()); - if (!source) { - return; - } - const length = Lengths.lengthOf(source.url); - const left = tab.mpvNextEpisodeUntil - Date.now(); - if (typeof length === 'number' && length > 0 && length < SHORT_S && left > MpvNextPickGraceMs) { - if (Logging) console.log('[MPV autoplay] a short stream, waiting for a longer one:', source.url, length); - tab.mpvNextPickTimer = setTimeout(() => { - if (tab.mpvNextPick === pick) pickNextEpisodeForMpv(tab); - }, left - MpvNextPickGraceMs); - return; - } - autoOpenInMpv(tab, source.url, source.headers); -} - -/** - * Every stream detected in a tab's frames, once each. - * @param {TabHolder} tab - * @return {Array} - */ -function tabSources(tab) { - const seen = new Set(); - /** @type {Array} */ - const sources = []; - for (const frame of tab.getFrames()) { - for (const source of frame.getSources()) { - if (!seen.has(source.url)) { - seen.add(source.url); - sources.push(source); - } - } - } - return sources; -} - -// MPV autoplay (his wish, 2026-10-11): an episode handed to mpv that plays to its end (with -// "Autoplay next episode in mpv" on), or mpv's key or button for the next or the previous -// episode, goes on to that episode in the tab's page, and its stream goes into the same mpv -// window, so a series plays episode after episode without the browser. The host (7) follows -// mpv over a port kept open (MpvBackend.watch), which also keeps this background page from -// unloading (measured, docs/notes/mpv.md). Host 9 (same day, his wishes): mpv shows episode buttons and an autoplay switch -// (the mpv setup's faststream-episodes.lua), which this setting drives and the switch flips; -// an end waits Options.autoplayCountdown seconds, which the switch or a key can cut short, -// and Space stop. -// The tab may be closed while mpv plays (his wish, 2026-10-27): the next and the previous -// episode's links are read from its page at each hand-off (rememberEpisodeLinks), and an end -// or a key then opens the episode in a tab not shown (goToEpisodeInNewTab), which closes again -// once its stream went to mpv. -/** - * The episode links of the page a stream went to mpv from, and where its tab was. - * @typedef {{tabId: number, next: ?string, previous: ?string, seriesEnd: boolean, - * cookieStoreId: ?string}} EpisodeLinksOfTab - */ -/** - * @typedef {{tabId: number, url: string, qualities: ?Array<{url: string}>, alive: boolean, going: boolean, - * countdown: ?ReturnType, countdownAsks: number, - * stalled: ?ReturnType, - * links: ?EpisodeLinksOfTab, closedTab: ?{links: EpisodeLinksOfTab, windowId: number, windowClosing: boolean}, - * watcher: ?{notify: Function, hide: Function, state: Function, stop: Function}}} MpvWatch - */ -/** @type {?MpvWatch} */ -let mpvWatch = null; -// How long the next page has to bring its stream (MPV key's mode: TabHolder.mpvNextEpisodeUntil). -const MpvNextEpisodeWaitMs = 60000; -// How long before that minute is up a short stream goes as the best there is -// (pickNextEpisodeForMpv). -const MpvNextPickGraceMs = 5000; -// How long mpv shows a notice that is a sentence to read (host 11; before, every notice went -// after notify.lua's 2 s). The countdown's stays for the countdown; on and off for 2 s. -const MpvNoticeSeconds = 6; - -/** - * The seconds between an episode's end and the next one (Options.autoplayCountdown). - * @return {number} 0 to 30. - */ -function autoplayCountdownSeconds() { - const seconds = Number(Options.autoplayCountdown); - return Number.isFinite(seconds) ? Math.min(30, Math.max(0, Math.round(seconds))) : 5; -} - /** * Stores one option the background changed itself (mpv's autoplay switch), and tells an open * options page, as its own saves do. @@ -3319,7 +2891,7 @@ function autoplayCountdownSeconds() { * @param {K} key * @param {PlayerOptions[K]} value */ -async function saveOption(key, value) { +export async function saveOption(key, value) { // Merged over DefaultOptions (Utils, which calls them an Object). const options = /** @type {PlayerOptions} */ (await Utils.getOptionsFromStorage()); options[key] = value; @@ -3357,148 +2929,12 @@ function sendOptionsToTabs(time) { }).catch((e) => console.error('Sending the new options to the tabs failed', e)); } -/** - * Stops an end's countdown, if one runs. - * @param {MpvWatch} state - */ -function cancelMpvCountdown(state) { - // A countdown still asking the page (startMpvCountdown) does not start either. - state.countdownAsks++; - if (state.countdown) { - clearTimeout(state.countdown); - state.countdown = null; - // Its notice stays for the whole countdown: a new stream, or autoplay switched off on - // the options page, would leave "next episode in 5 s" up with nothing coming. - state.watcher?.hide(); - } -} - -/** - * What mpv's autoplay switch shows: after the watch began, and when the setting changed. - * @param {?MpvWatch} state - */ -function syncMpvAutoplay(state) { - if (!state?.alive) return; - if (!Options.mpvAutoplayNext) cancelMpvCountdown(state); - state.watcher?.state({autoplay: !!Options.mpvAutoplayNext}); -} - -/** - * Follows the mpv a stream of the tab just went to: the latest hand-off is the one an end or - * a key goes on from. Only a single mpv window (Mpv.singleInstance), which the host can - * follow; a window per stream has no pipe. - * @param {TabHolder} tab - * @param {string} url - The stream sent. - * @param {?Array<{url: string}>} [qualities] - The page's qualities of it that went along - * (isSentStream). - */ -function watchMpvFor(tab, url, qualities = null) { - if (!tab || typeof tab.tabId !== 'number' || !isTabOpen(tab) || !Mpv.singleInstance) { - return; - } - // The next episode came: what was asked for it is done. - if (tab.continuationOptions?.mpvNext) { - tab.continuationOptions = null; - } - tab.mpvNextEpisodeUntil = 0; - // Another tab's stream took the window: the episode that tab still waits for no longer - // goes to mpv by itself, where it would replace this one. - const before = mpvWatch && mpvWatch.tabId !== tab.tabId ? Tabs.getTab(mpvWatch.tabId) : null; - if (before) { - before.mpvNextEpisodeUntil = 0; - if (before.continuationOptions?.mpvNext) { - before.continuationOptions = null; - } - // One autoplay opened for an episode that no longer goes to mpv. - closeReopenedTab(before); - } - if (mpvWatch?.alive) { - // A stream arrived: the next page's "no stream" alarm, and an end's countdown, are done. - if (mpvWatch.stalled) clearTimeout(mpvWatch.stalled); - mpvWatch.stalled = null; - cancelMpvCountdown(mpvWatch); - mpvWatch.tabId = tab.tabId; - mpvWatch.url = url; - mpvWatch.qualities = qualities; - // A tab that is open now is what an end goes on from. The links of the tab's page before - // are not this one's: closed before these are read, it has none (glm's review). - mpvWatch.closedTab = null; - mpvWatch.links = null; - rememberEpisodeLinks(mpvWatch, tab); - return; - } - /** @type {MpvWatch} */ - const state = {tabId: tab.tabId, url, qualities, alive: true, going: false, countdown: null, countdownAsks: 0, - stalled: null, links: null, closedTab: null, watcher: null}; - mpvWatch = state; - state.watcher = Mpv.watch((event) => onMpvWatchEvent(state, event)); - rememberEpisodeLinks(state, tab); -} - -/** How long rememberEpisodeLinks waits to read the links again when the page had none yet. */ -const EpisodeLinksAgainMs = 10000; - -/** - * Reads the next and the previous episode's links of the page whose stream just went to mpv, - * for an end or a key to go on with once its tab is closed (goToEpisodeInNewTab), through - * EPISODE_LINKS, which clicks nothing. A page that lists its episodes only a while after its - * stream (a list it fetches) is read once more, also in a tab autoplay opened for the episode - * itself (TabHolder.mpvReopened), which closes once read. - * @param {MpvWatch} state - * @param {TabHolder} tab - * @return {Promise} - */ -async function rememberEpisodeLinks(state, tab) { - const page = tab.mpvPage; - const stillThePage = () => state.alive && mpvWatch === state && state.tabId === tab.tabId && isTabOpen(tab) && - tab.mpvPage === page; - let links = await readEpisodeLinks(tab); - if (!stillThePage()) return; - state.links = links; - if (!links.next && !links.seriesEnd) { - await new Promise((resolve) => setTimeout(resolve, EpisodeLinksAgainMs)); - if (!stillThePage()) return; - links = await readEpisodeLinks(tab); - if (!stillThePage()) return; - state.links = links; - } - if (tab.mpvReopened) { - closeReopenedTab(tab); - } -} - -/** - * The page's episode links (EPISODE_LINKS), from its top frame only: the background opens one - * later as a tab of its own, and a frame's (a hoster's, an ad's) went there in the tab's - * container (glm's review, 2026-10-27), where goToEpisode, with the tab open, has such a frame - * go on only within itself. A page that does not answer within MpvPagePollMs cannot tell. - * @param {TabHolder} tab - * @return {Promise} - */ -async function readEpisodeLinks(tab) { - /** @type {EpisodeLinksOfTab} */ - const links = {tabId: tab.tabId, next: null, previous: null, seriesEnd: false, cookieStoreId: null}; - const answer = await askFrame(tab.tabId, {type: MessageTypes.EPISODE_LINKS}, {frameId: 0}, 'episode_links', - MpvPagePollMs); - const address = (/** @type {*} */ value) => typeof value === 'string' && /^https?:\/\//i.test(value) ? value : null; - if (answer && typeof answer === 'object' && !answer.error) { - links.next = answer.hasNext === true ? address(answer.next) : null; - links.seriesEnd = answer.hasNext !== true && answer.seriesEnd === true; - links.previous = address(answer.previous); - } - // Its container, which the episode's tab opens in: the same cookies, the same login. The - // default and the private store go by the window (as the DOWNLOAD message's tab does). - const store = await chrome.tabs.get(tab.tabId).then((info) => info?.cookieStoreId || null, () => null); - links.cookieStoreId = store && store !== 'firefox-default' && store !== 'firefox-private' ? store : null; - return links; -} - /** * Closes a tab MPV autoplay opened for an episode (TabHolder.mpvReopened), unless the user * looks at it now: then it is the user's, and stays. * @param {TabHolder} tab */ -function closeReopenedTab(tab) { +export function closeReopenedTab(tab) { if (!tab?.mpvReopened) return; tab.mpvReopened = false; chrome.tabs.get(tab.tabId).then((info) => { @@ -3509,19 +2945,6 @@ function closeReopenedTab(tab) { }); } -/** - * The tab mpv's stream came from is closed (tabs.onRemoved): the episode links its page had - * stay with the watch, for an end or a key to go on in a new tab (goToEpisodeInNewTab). - * @param {number} tabId - * @param {{windowId: number, isWindowClosing: boolean}} removed - */ -function noteMpvTabClosed(tabId, removed) { - const state = mpvWatch; - if (!state?.alive || state.tabId !== tabId || state.links?.tabId !== tabId) return; - state.closedTab = {links: state.links, windowId: removed.windowId, windowClosing: !!removed.isWindowClosing}; - if (Logging) console.log('[MPV autoplay] its tab was closed; the episodes go on in a new one:', state.links); -} - /** * Whether mpv plays the stream FastStream sent: the host appends its fs-* tags after a #, * and the rest is the URL as sent, as the URL parser writes it (mpvTargetUrl): a URL sent @@ -3535,7 +2958,7 @@ function noteMpvTabClosed(tabId, removed) { * @param {?Array<{url: string}>} [qualities] - The page's qualities sent with it. * @return {boolean} */ -function isSentStream(path, url, qualities = null) { +export function isSentStream(path, url, qualities = null) { if (typeof path !== 'string' || typeof url !== 'string') return false; const playing = path.split('#')[0]; const parsedWithoutHash = /** @param {string} candidate */ (candidate) => { @@ -3551,364 +2974,16 @@ function isSentStream(path, url, qualities = null) { return (Array.isArray(qualities) ? qualities : []).some((entry) => parsedWithoutHash(entry?.url) === playing); } -/** - * What the host says about the watched mpv. - * @param {MpvWatch} state - The watch (mpvWatch). - * @param {import('./MpvBackend.mjs').MpvWatchEvent} event - watching, path, ended, request, - * decoder or closed. - */ -function onMpvWatchEvent(state, event) { - if (event.type === 'closed') { - state.alive = false; - cancelMpvCountdown(state); - if (state.stalled) clearTimeout(state.stalled); - state.stalled = null; - if (mpvWatch === state) mpvWatch = null; - // mpv is gone (both watches' reasons for it): the page's next play is the user's, not - // the player repeating itself. Within MpvPlayRepeatMs it only paused the page, and - // closing mpv quickly left a dead click. A helper port that went while mpv plays on - // keeps the window: a repeat would load the episode again from where the page is. - const tab = event.reason === 'mpv closed' || event.reason === 'no mpv' ? Tabs.getTab(state.tabId) : null; - if (tab?.mpvLastPlaySend) { - tab.mpvLastPlaySend = null; - Tabs.saveTabState(tab); - } - // A tab autoplay opened for an episode mpv no longer waits for. - if (tab) closeReopenedTab(tab); - if (Logging) console.log('[MPV autoplay] the watch ended:', event.reason); - return; - } - if (event.type === 'watching') { - syncMpvAutoplay(state); - } else if (event.type === 'decoder') { - // What the toolbar's tooltip names, for the tab whose stream mpv plays (followMpvDecoder). - const tab = Tabs.getTab(state.tabId); - if (tab) setMpvDecoder(tab, event.decoder); - } else if (event.type === 'ended') { - if (Options.mpvAutoplayNext && isSentStream(event.path, state.url, state.qualities)) { - startMpvCountdown(state); - } - } else if (event.type === 'request' && (event.action === 'next' || event.action === 'previous')) { - // A key or a button: now, whatever the countdown says. - cancelMpvCountdown(state); - goToEpisode(state, event.action); - } else if (event.type === 'request' && event.action === 'autoplay') { - toggleMpvAutoplay(state); - } else if (event.type === 'request' && event.action === 'pause') { - // Space at an end (his wish, 2026-10-17: the easiest key): this episode's countdown stops, - // and autoplay stays on for the next end, which the switch would have turned off. mpv - // stays paused on the last frame. Space during playback finds no countdown to stop. - const counting = !!state.countdown; - cancelMpvCountdown(state); - if (counting) { - state.watcher?.notify(chrome.i18n.getMessage('mpv_autoplay_title') || 'Autoplay', - chrome.i18n.getMessage('mpv_autoplay_stopped') || 'stopped (n: next episode)'); - } - } -} - -/** How long startMpvCountdown waits for each frame of the page to answer. */ -const MpvPagePollMs = 1000; -/** - * How long goToEpisode waits for a frame to answer its next/previous request (the content - * script answers at once, after its click): a frame that never answered kept state.going - * set, and every later key, button and autoplay of the session returned at it (glm's - * review, 2026-10-12). - */ -const MpvPageNavigateMs = 5000; - -/** - * What the page of the tab mpv's stream came from says about its next episode, asking its - * frames top first as goToEpisode does, through PLAYLIST_POLL, which clicks nothing: - * 'series_end' when the first frame that knows says the series ends here (no other frame is - * asked: a hoster's links are other videos), 'next' when it has a next episode, null when no - * frame can tell. - * @param {TabHolder} tab - * @return {Promise} - */ -async function nextEpisodeInPage(tab) { - const frameIds = tab.frames ? Array.from(tab.frames.keys()).sort((a, b) => a - b) : []; - for (const frameId of frameIds) { - // A frame that never answers costs a second, not the countdown (glm's review): no - // answer is "cannot tell". - const answer = await new Promise((resolve) => { - const timer = setTimeout(() => resolve(undefined), MpvPagePollMs); - chrome.tabs.sendMessage(tab.tabId, {type: MessageTypes.PLAYLIST_POLL}, {frameId}, (response) => { - void chrome.runtime.lastError; - clearTimeout(timer); - resolve(response); - }); - }); - if (answer && typeof answer === 'object') { - if (answer.next === true) return 'next'; - if (answer.seriesEnd === true) return 'series_end'; - } - } - return null; -} - /** * mpv's notice that autoplay stops at the series' last episode. * @param {MpvWatch} state */ -function notifySeriesEnd(state) { +export function notifySeriesEnd(state) { state.watcher?.notify(chrome.i18n.getMessage('player_next_video_label') || 'Next video', chrome.i18n.getMessage('mpv_episode_series_end') || 'the last episode of the series: autoplay stops', MpvNoticeSeconds); } -/** - * An episode ended in mpv with autoplay on: the next one after the countdown, which mpv - * shows. Space stops it, the autoplay switch too (and autoplay with it); the next-episode - * key or button skips it. - * - * The page is asked first (2026-10-12, the mpv setup's TODO): at the series' last episode - * mpv said "next episode in 5 s", and only when the countdown ran out that autoplay stops. - * Now it says the series ended at once, and counts down nothing. A page that cannot tell - * counts down as before (goToEpisode then says what it finds). - * @param {MpvWatch} state - * @return {Promise} - */ -async function startMpvCountdown(state) { - if (state.countdown) return; - const seconds = autoplayCountdownSeconds(); - if (seconds === 0) { - goToEpisode(state, 'next'); - return; - } - // A key, the switch, mpv closing or another end while the page is asked wins over this one. - const ask = ++state.countdownAsks; - const tab = Tabs.getTab(state.tabId); - // A closed tab's page said it when its stream went to mpv (rememberEpisodeLinks). - const page = tab && tab.isOn && tab.isMpv ? await nextEpisodeInPage(tab) : - !tab && state.closedTab?.links.seriesEnd ? 'series_end' : null; - if (ask !== state.countdownAsks || state.countdown || !state.alive || !Options.mpvAutoplayNext) return; - if (page === 'series_end') { - notifySeriesEnd(state); - return; - } - state.watcher?.notify(chrome.i18n.getMessage('mpv_autoplay_title') || 'Autoplay', - chrome.i18n.getMessage('mpv_autoplay_countdown', [String(seconds)]) || - `next episode in ${seconds} s (n: now, Space: stop)`, seconds); - state.countdown = setTimeout(() => { - state.countdown = null; - if (state.alive && Options.mpvAutoplayNext) goToEpisode(state, 'next'); - }, seconds * 1000); -} - -/** - * mpv's autoplay switch: flips the setting, which mpv then shows. - * @param {MpvWatch} state - * @return {Promise} - */ -function toggleMpvAutoplay(state) { - // One after the other: two quick presses read the same setting before either was saved, - // and the second was lost. - mpvAutoplayToggles = mpvAutoplayToggles.then(async () => { - const on = !Options.mpvAutoplayNext; - try { - await saveOption('mpvAutoplayNext', on); - } catch (e) { - console.error('Saving MPV autoplay failed', e); - return; - } - syncMpvAutoplay(state); - state.watcher?.notify(chrome.i18n.getMessage('mpv_autoplay_title') || 'Autoplay', - chrome.i18n.getMessage(on ? 'mpv_autoplay_on' : 'mpv_autoplay_off') || (on ? 'on' : 'off')); - }); - return mpvAutoplayToggles; -} -/** The switch presses not done yet (toggleMpvAutoplay). */ -let mpvAutoplayToggles = Promise.resolve(); - -/** - * Goes on to the next (or the previous) episode in the page of the tab mpv's stream came - * from: the page's own link, as the in-page player's next button does it, top frame first. - * The next page's player is started if it waits for a click (startPagePlayers), and its - * stream goes to mpv by itself, into the same window. mpv says what happens. - * @param {MpvWatch} state - The watch (mpvWatch). - * @param {'next'|'previous'} direction - */ -async function goToEpisode(state, direction) { - const title = chrome.i18n.getMessage(direction === 'next' ? 'player_next_video_label' : 'player_previous_video_label') || - (direction === 'next' ? 'Next video' : 'Previous video'); - const tab = Tabs.getTab(state.tabId); - if (!tab && state.closedTab) { - return goToEpisodeInNewTab(state, direction, title); - } - if (!tab || !tab.isOn || !tab.isMpv) { - notifyEpisodeUnavailable(state, title); - return; - } - // An end and a key at the same moment would click the page's link twice, a skipped episode. - if (state.going) { - return; - } - state.going = true; - const frameIds = Array.from(tab.frames.keys()).sort((a, b) => a - b); - let clicked = false; - let seriesEnd = false; - try { - for (const frameId of frameIds) { - const answer = await new Promise((resolve) => { - const timer = setTimeout(() => resolve('timeout'), MpvPageNavigateMs); - chrome.tabs.sendMessage(tab.tabId, {type: MessageTypes.PLAYLIST_NAVIGATION, direction}, {frameId}, (response) => { - void chrome.runtime.lastError; - clearTimeout(timer); - resolve(response); - }); - }); - // No other frame is asked: this one may still click, and a hoster's link after it - // would be a second episode. - if (answer === 'timeout') break; - if (answer === 'clicked') { - clicked = true; - break; - } - // The page is the series' last episode (content.js isSeriesEnd): no other frame (a - // hoster's, whose links are other videos) is asked. - if (answer === 'series_end') { - seriesEnd = true; - break; - } - } - } finally { - state.going = false; - } - // Another tab's stream took mpv while the page was asked (watchMpvFor moves the watch to - // it): what this page did is no longer what mpv waits for. The old tab got the next - // episode's wait all the same, and a minute later mpv said "no stream" over the other - // tab's video (glm's second look, 2026-10-13). - if (state.tabId !== tab.tabId || !state.alive) { - return; - } - if (seriesEnd) { - notifySeriesEnd(state); - return; - } - if (!clicked) { - state.watcher?.notify(title, chrome.i18n.getMessage('mpv_episode_none') || 'none found on the page', - MpvNoticeSeconds); - noteProblem(tab.tabId, {url: tab.url, kind: ProblemKinds.NO_NEXT, mode: 'mpv'}).catch((e) => console.error(e)); - return; - } - if (Logging) console.log('[MPV autoplay] went on:', direction, tab.tabId); - awaitEpisodeStream(state, tab, title); -} - -/** - * mpv's notice that the episode cannot be gone to: its tab is closed (and its page had no - * link to it, or its window went), or not in MPV mode. - * @param {MpvWatch} state - * @param {string} title - */ -function notifyEpisodeUnavailable(state, title) { - state.watcher?.notify(title, chrome.i18n.getMessage('mpv_episode_unavailable') || - 'its tab is closed, or not in MPV mode', MpvNoticeSeconds); -} - -/** - * goToEpisode with the tab of mpv's stream closed (his wish, 2026-10-27): the episode its page - * linked to (rememberEpisodeLinks) opens in a new tab that is not shown, in the window and the - * container the closed one was in, and goes on as the tab did (awaitEpisodeStream). Measured - * in Firefox 158: such a tab loads the hoster, its player is started, and its stream is - * asked for, while Firefox holds the page's own play() until the tab is shown. Once the stream - * went to mpv, the tab closes again (rememberEpisodeLinks). Not when the closed tab's window - * went with it, nor for a page whose next was a button, not a link. - * @param {MpvWatch} state - * @param {'next'|'previous'} direction - * @param {string} title - mpv's notice's title. - * @return {Promise} - */ -async function goToEpisodeInNewTab(state, direction, title) { - const closed = state.closedTab; - if (!closed) return; - const url = direction === 'next' ? closed.links.next : closed.links.previous; - if (!url) { - if (direction === 'next' && closed.links.seriesEnd) { - notifySeriesEnd(state); - } else { - notifyEpisodeUnavailable(state, title); - } - return; - } - // Its window went with it; or MPV mode was switched off since (leaveMpvWhenOff), which a - // tab still open shows by its isMpv (glm's review). - if (closed.windowClosing || !Options.mpvMode) { - notifyEpisodeUnavailable(state, title); - return; - } - // An end and a key at the same moment would open the episode twice. - if (state.going) { - return; - } - state.going = true; - /** @type {?chrome.tabs.Tab} */ - let created = null; - try { - created = await chrome.tabs.create({url, active: false, windowId: closed.windowId, - ...(closed.links.cookieStoreId ? {cookieStoreId: closed.links.cookieStoreId} : {})}); - } catch (e) { - console.error('Opening the next episode\'s tab failed', e); - } finally { - state.going = false; - } - if (!created || typeof created.id !== 'number') { - notifyEpisodeUnavailable(state, title); - return; - } - // mpv closed, or another tab's stream took it, while the tab was made. - if (!state.alive || mpvWatch !== state || state.closedTab !== closed) { - chrome.tabs.remove(created.id).catch(() => {}); - return; - } - state.closedTab = null; - state.links = null; - state.tabId = created.id; - // MPV, as the closed tab was: the lists by address decide nothing for it (tabs.onUpdated - // keeps a tab that is on already). Matched by the MPV allowlist already, where it names the - // address: its own start would send the page's first stream, an ad's maybe, beside the - // episode's pick (glm's review). - const tab = Tabs.getTabOrCreate(created.id); - tab.isOn = true; - tab.isMpv = true; - tab.mpvOnPlay = false; - tab.mpvMatched = MpvAllowlist.matches(url); - tab.mpvReopened = true; - BackgroundUtils.updateTabIcon(tab); - if (Logging) console.log('[MPV autoplay] went on in a new tab:', direction, created.id, url); - awaitEpisodeStream(state, tab, title); - // Its frames may have come before the continuation did (FRAME_ADDED starts this too). - startPagePlayers(tab); -} - -/** - * The tab went on to the episode: its stream goes to mpv by itself (onSourceRecieved, - * pickNextEpisodeForMpv) for a minute, its page's player is started if it waits for a click - * (startPagePlayers), and mpv says what happens. - * @param {MpvWatch} state - * @param {TabHolder} tab - * @param {string} title - mpv's notice's title. - */ -function awaitEpisodeStream(state, tab, title) { - tab.continuationOptions = {autoPlay: true, mpvNext: true}; - tab.mpvNextEpisodeUntil = Date.now() + MpvNextEpisodeWaitMs; - // Not for a tab closed meanwhile: its state went back into storage (isTabOpen; glm's review). - if (isTabOpen(tab)) Tabs.saveTabState(tab); - state.watcher?.notify(title, chrome.i18n.getMessage('mpv_episode_loading') || 'loading in mpv', - MpvNoticeSeconds); - // The page gone to had its minute and brought no stream (a locked episode, a dead hoster, - // a login wall): mpv says so, where it otherwise sat at the old episode's end in silence. - if (state.stalled) clearTimeout(state.stalled); - state.stalled = setTimeout(() => { - state.stalled = null; - if (!state.alive || mpvWatch !== state || state.tabId !== tab.tabId) return; - noteProblem(tab.tabId, {url: tab.url, kind: ProblemKinds.NO_STREAM, mode: 'mpv'}).catch((e) => console.error(e)); - state.watcher?.notify(title, chrome.i18n.getMessage('mpv_episode_nostream') || - 'the page brought no stream within a minute; open it in the browser to see why', MpvNoticeSeconds); - }, MpvNextEpisodeWaitMs); -} - /** * Whether the tracker still holds the tab. A closed tab's holder lives on in the callbacks * of what it sent, and a state saved for it put the closed tab back into storage.session, @@ -3916,111 +2991,17 @@ function awaitEpisodeStream(state, tab, title) { * @param {TabHolder} tab * @return {boolean} */ -function isTabOpen(tab) { +export function isTabOpen(tab) { return Tabs.getTab(tab.tabId) === tab; } -/** - * Whether the host's answer to a hand-off still belongs to the tab's page: the tab is open - * and shows the page the stream was sent from (tab.mpvPage). A fresh mpv answers seconds - * after the send. An answer for the page before paused the next page's video with nothing - * sent for it, showed that page's failure or decoder on the button, and a failure opened - * the allowlist's hand-off again, which then sent the next page's second stream as well. - * @param {TabHolder} tab - The tab the stream was sent from. - * @param {number} page - Its tab.mpvPage when the stream was sent. - * @return {boolean} - */ -function isHandOffPage(tab, page) { - return isTabOpen(tab) && tab.mpvPage === page; -} - -/** - * Shows a failed MPV-mode hand-off on the tab's toolbar button ("!", and the reason in - * its tooltip), or clears it after one that worked. Without it the page simply played - * on in the browser, and nothing said why. An outdated host is kept too, for the tooltip - * alone (no "!" since the thin helper, 2026-10-13: an older one still plays the stream): - * it says to install the host again, as the copy a PC runs is not updated by an extension - * update or a `git pull` (MpvBackend's RequiredHostVersion). - * @param {TabHolder} tab - The tab the stream was sent from. - * @param {{ok: boolean, error?: string, noHost?: boolean, hostOutdated?: boolean}} result - - * The host's answer. - */ -function setMpvError(tab, result) { - if (!isTabOpen(tab)) { - return; - } - /** @type {?string} */ - let error = null; - if (!result.ok) { - error = result.noHost || !result.error ? - chrome.i18n.getMessage('player_mpv_no_host') || 'the FastStream mpv helper did not answer - is it installed?' : - result.error; - } - const hostOutdated = result.hostOutdated === true; - if (tab.mpvError !== error || tab.mpvHostOutdated !== hostOutdated) { - tab.mpvError = error; - tab.mpvHostOutdated = hostOutdated; - BackgroundUtils.updateTabIcon(tab); - Tabs.saveTabState(tab); - } -} - -// How long the host may wait, after a hand-off, for mpv to start decoding: a slow stream -// takes seconds to open, and until then mpv has no decoder to name. -const MpvDecoderWaitMs = 20000; - -/** - * After a hand-off that worked, asks mpv which video decoder it plays the stream with, - * for the toolbar button's tooltip: the hardware API, or a hint that mpv decodes in - * software (with what to add to mpv.conf). Read-only: FastStream never overrides mpv.conf. - * Only an mpv the host started for single-instance use has the pipe to ask over. - * @param {TabHolder} tab - The tab the stream was sent from. - * @param {{ok: boolean}} result - The hand-off's answer. - */ -function followMpvDecoder(tab, result) { - const query = {}; - tab.mpvDecoderQuery = query; - setMpvDecoder(tab, null); - if (!result.ok || !Mpv.singleInstance) { - return; - } - // The thin helper's relay, which the watch holds open, tells the decoder as mpv loads it - // (onMpvWatchEvent). Asked here, each second until mpv had one meant a new helper process - // (cmd and node) every second, up to MpvDecoderWaitMs (review of 2026-10-14). - if (Mpv.mayUse(Capabilities.RELAY)) { - return; - } - Mpv.decoderStatus(MpvDecoderWaitMs).then((status) => { - // A later hand-off (or a new page) asks again: this answer is about an older stream. - if (tab.mpvDecoderQuery !== query) { - return; - } - setMpvDecoder(tab, status.ok && status.running ? status.decoder || null : null); - }).catch((e) => console.warn('Asking mpv for its decoder failed', e)); -} - -/** - * @param {TabHolder} tab - * @param {?import('./MpvBackend.mjs').MpvDecoder} decoder - */ -function setMpvDecoder(tab, decoder) { - // The answer takes up to MpvDecoderWaitMs, and the tab is often closed by then: the - // stream plays in mpv now. - if (!isTabOpen(tab) || JSON.stringify(tab.mpvDecoder ?? null) === JSON.stringify(decoder)) { - return; - } - tab.mpvDecoder = decoder; - BackgroundUtils.updateTabIcon(tab); - Tabs.saveTabState(tab); -} - /** * A tab's title, for mpv's window and top bar (the native host shows the stream's host * name without one). * @param {number} tabId - The tab. * @return {Promise} Its title, or undefined when it cannot be read. */ -async function tabTitle(tabId) { +export async function tabTitle(tabId) { try { const t = await chrome.tabs.get(tabId); return (t && t.title) || undefined; @@ -4279,7 +3260,7 @@ async function findPlayedSource(tab, frameId, src, video) { * @param {?PlayedVideo} [video] - What the page's video plays. * @return {?TrackedSource} One of them, or null when there are none. */ -function newestOfLongest(sources, video = null) { +export function newestOfLongest(sources, video = null) { const lengths = Lengths.lengthsOf(sources); /** @type {Array<{source: *, url: string, duration?: ?number}>} */ const measured = StreamLength.withoutStills(sources.map((source, i) => ({source, url: source.url, duration: lengths[i]}))) @@ -4331,119 +3312,6 @@ function sendPageMediaHold(tab) { }); } -/** - * Hands a video the user started to mpv, then pauses the page. - * - * The same video again within MpvPlayRepeatMs is the player repeating itself - * (play() called twice, or resuming after pauseTabMedia), so it only pauses - * the page again. openStream gets no tab: its per-page dedupe would swallow a - * later, deliberate play of a video already sent (after closing mpv). - * - * @param {TabHolder} tab - The tab the video is in. - * @param {{url: string, headers: *}} source - Detected source: url and request headers. - * @param {?{time?: number, duration?: ?number}} [video] - The page's video as it played - * (content.js playedVideo): mpv starts where it was. It started at 0:00, and the page, - * paused at the user's place, kept it. - */ -function sendPlayedToMpv(tab, source, video = null) { - // As autoOpenInMpv: a tab closed while its stream was looked for sends nothing. - if (!isTabOpen(tab)) { - return; - } - const now = Date.now(); - const last = tab.mpvLastPlaySend; - if (last && last.url === source.url && now - last.time < MpvPlayRepeatMs) { - pauseTabMedia(tab.tabId); - return; - } - - tab.mpvLastPlaySend = {url: source.url, time: now}; - Tabs.saveTabState(tab); - const page = tab.mpvPage; - // Only a video of a known length (not a live one), and past its first seconds: a play - // from the start starts at the start. - const time = video?.time; - const startTime = Number.isFinite(video?.duration) && typeof time === 'number' && time > 5 ? time : undefined; - tabTitle(tab.tabId).then((title) => - openInMpv(tab.tabId, source.url, null, source.headers, resolveMpvContentType(null, tab.url), tab.url, title, - {startTime})).then((result) => { - if (Logging) console.log('[MPV] user play result:', source.url, JSON.stringify(result)); - if (!isHandOffPage(tab, page)) { - return; - } - setMpvError(tab, result); - followMpvDecoder(tab, result); - if (result.ok) { - pauseAfterHandOff(tab, result); - } else if (tab.mpvLastPlaySend && tab.mpvLastPlaySend.url === source.url) { - // The host never launched mpv, so let the next play try again. - tab.mpvLastPlaySend = null; - Tabs.saveTabState(tab); - } - }).catch((e) => { - console.error('Handing the played video to mpv failed', e); - if (isHandOffPage(tab, page)) { - setMpvError(tab, {ok: false, error: String(e)}); - } - }); -} - -/** - * Sends the newest of the longest sources tracked on the tab to mpv via the - * native messaging host. - * - * Only one source is sent. mpv opens a window per invocation, and a page - * routinely exposes several sources (ads, previews, one per quality), so - * sending them all would bury the user in mpv windows. An ad or a preview - * runs for seconds, the video for minutes, and of those that tie the newest - * is the one the page just started playing; the rest stay tracked, so the - * player's "send to mpv" button can still reach them. - * - * @param {TabHolder} tab - The tab whose tracked sources should open in mpv. - * @return {boolean} True when a source was handed to mpv. - */ -function openMpvWithSources(tab) { - if (!Options.mpvMode) { - return false; - } - - // Entering MPV mode is an explicit start, so forget what was already sent - // for this tab. Without this the dedupe in openStream silently swallows a - // re-entry that targets the same URL (toolbar cycled MPV -> Off -> On -> MPV). - tab.mpvSentUrls.clear(); - - const source = newestOfLongest(tabSources(tab)); - if (!source) { - return false; - } - - tab.mpvAutoOpened = true; - Tabs.saveTabState(tab); - const page = tab.mpvPage; - tabTitle(tab.tabId).then((title) => - openInMpv(tab.tabId, source.url, tab, source.headers, resolveMpvContentType(null, tab.url), tab.url, title)).then((result) => { - if (Logging) console.log('[MPV] openStream result:', source.url, JSON.stringify(result)); - if (!isHandOffPage(tab, page)) { - return; - } - setMpvError(tab, result); - followMpvDecoder(tab, result); - if (result.ok) { - pauseAfterHandOff(tab, result); - } else { - // The host never launched mpv, so let the next detected stream try. - tab.mpvAutoOpened = false; - Tabs.saveTabState(tab); - } - }).catch((e) => { - console.error('Handing the stream to mpv failed', e); - if (isHandOffPage(tab, page)) { - setMpvError(tab, {ok: false, error: String(e)}); - } - }); - return true; -} - /** * The quality of a stream's file the user's setting asks for, when the page's player offers * the file in several (page-qualities.js), asked of the frame the stream was found in. @@ -4451,7 +3319,7 @@ function openMpvWithSources(tab) { * @param {string} url - The stream. * @return {Promise}>} The file to play and the list, or null. */ -async function pageQualityChoice(tab, url) { +export async function pageQualityChoice(tab, url) { if (!tab) return null; for (const frame of tab.getFrames()) { const source = frame.getSources().find(/** @param {TrackedSource} s */ (s) => s.url === url || s.requestedUrl === url); @@ -4479,7 +3347,7 @@ async function pageQualityChoice(tab, url) { * @return {Promise}>} What to send instead, and the list; * null to send the stream as it is. */ -async function hlsQualityChoice(tab, url) { +export async function hlsQualityChoice(tab, url) { let hls = Lengths.hlsQualities(url); if (!hls) { // The list comes from the master playlist's read, which started when it was detected. An @@ -4507,116 +3375,6 @@ async function hlsQualityChoice(tab, url) { return {url: best ? best.url : hls.master, list: hls.list}; } -/** - * Hands a stream to mpv, unless the page's request for it went through Firefox VPN: mpv - * runs outside Firefox, asks from the user's own address, and a site that ties its stream - * to the VPN's refuses it ("could not open the stream", VOE, 2026-10-06). Then mpv is not - * started, and the answer says why, where a failed hand-off shows (the toolbar button's - * "!" and tooltip, the player's status line). After the tab, arguments as - * MpvBackend.openStream. - * @param {number|undefined} tabId - The tab the stream is from: whether it is a private - * window decides which of the VPN's hosts count (VpnProxyMirror.throughBrowserVpn). - * @param {string} url - The stream. - * @param {...*} rest - openStream's other arguments. - * @return {Promise} openStream's answer, or {ok: false, vpn: true, error}, or - * {ok: false, drm: true, error}. - */ -async function openInMpv(tabId, url, ...rest) { - let incognito; - if (tabId !== undefined && tabId >= 0) { - try { - incognito = !!(await chrome.tabs.get(tabId))?.incognito; - } catch (e) { - // The tab went: both kinds of window count. - } - } - // The stream the page asked for: Firefox VPN is known to carry it by its host - // (VpnProxyMirror.noteSource). A quality file taken in its place may be on a host that check - // never saw - a hoster's get_file link, which redirects to its CDN - and it went to mpv on - // that file's host alone (this review, 2026-10-13). - const detected = url; - // The page's player offers this file in other qualities too (page-qualities.js): mpv gets - // the one the user's quality setting asks for, and the list. MPV mode sent the page's - // default, the lowest on a film site's hoster. Not for the player's button, which sends the - // one it plays and its own list (extras.qualities). - if (tabId !== undefined && tabId >= 0 && !(rest[5] && 'qualities' in rest[5])) { - const choice = await pageQualityChoice(Tabs.getTab(tabId), url); - if (choice) { - if (Logging && choice.url !== url) console.log('[MPV] the page\'s best quality instead:', choice.url); - url = choice.url; - rest[5] = {...(rest[5] || {}), qualities: choice.list}; - } - } - // An HLS stream: the quality its master playlist offers that the setting asks for, and the - // list for mpv's quality menu (hlsQualityChoice). Not for the player's button either. - if (!(rest[5] && 'qualities' in rest[5])) { - const choice = await hlsQualityChoice(tabId !== undefined && tabId >= 0 ? Tabs.getTab(tabId) : null, url); - if (choice) { - if (Logging && choice.url !== url) console.log('[MPV] the HLS stream\'s best quality instead:', choice.url); - url = choice.url; - if (choice.list) rest[5] = {...(rest[5] || {}), qualities: choice.list}; - } - } - // Copy-protected (DRM, StreamLengths.isProtected): mpv has no key system either, and its - // window stayed black where the page's player plays the video. Its manifest's read tells, - // which started when it was detected, with the tab on; a master's tells for its variants. - // An HLS stream's is waited for above (hlsQualityChoice) when it gives the qualities; the - // player's button gives its own, a DASH stream none, and a stream detected while the tab - // was off was not read: waited for here, as long. - const tracked = tabId !== undefined && tabId >= 0 && Tabs.getTab(tabId) ? - tabSources(Tabs.getTab(tabId)).find((source) => source.url === detected) : null; - if (tracked && (tracked.mode === PlayerModes.ACCELERATED_HLS || tracked.mode === PlayerModes.ACCELERATED_DASH)) { - await Lengths.settle(() => [tracked], HlsQualitiesWaitMs); - } - if (Lengths.isProtected(detected) || Lengths.isProtected(url)) { - if (Logging) console.log('[MPV] not handed off, copy-protected:', url); - return { - ok: false, - drm: true, - error: chrome.i18n.getMessage('player_mpv_drm') || - 'This video is copy-protected (DRM), and mpv cannot play it: the site\'s own player keeps it.', - }; - } - if (VpnMirror.throughBrowserVpn(detected, incognito) || VpnMirror.throughBrowserVpn(url, incognito)) { - if (Logging) console.log('[MPV] not handed off, Firefox VPN carries it:', url); - return Promise.resolve({ - ok: false, - vpn: true, - error: chrome.i18n.getMessage('player_mpv_vpn') || - 'Firefox VPN carries this video, and mpv cannot use it. Turn Firefox VPN off for this site and reload the page.', - }); - } - // The page's subtitles go with its stream, as the in-page player shows them: MPV mode sent - // the stream alone. The player's button sends the tracks it shows (subtitles given, none - // too), which stay its choice; a stream already sent asks nothing (openStream). - const tabHolder = rest[0]; - const extras = rest[5]; - if (tabId !== undefined && tabId >= 0 && !(extras && 'subtitles' in extras) && - !tabHolder?.mpvSentUrls?.has(url)) { - const tab = Tabs.getTab(tabId); - const subtitles = tab ? await pageSubtitlesForMpv(tab).catch((e) => { - console.error('Reading the subtitles of the page for mpv failed', e); - return []; - }) : []; - if (subtitles.length > 0) { - if (Logging) console.log('[MPV] subtitles of the page go along:', subtitles.map((s) => s.label)); - rest[5] = {...(extras || {}), subtitles}; - } - } - if (incognito) { - // A private window's stream: mpv keeps no resume position for it (the owner's pick, - // 2026-10-14; fs-private, MpvTarget.mjs). - rest[5] = {...(rest[5] || {}), private: true}; - } - const result = await Mpv.openStream(url, ...rest); - // MPV autoplay: the mpv it went to is followed (watchMpvFor). By the tab's id: the - // player's button and the MPV key's play pass no tab (openStream's duplicate check). - if (result && result.ok && tabId !== undefined && tabId >= 0) { - watchMpvFor(Tabs.getTab(tabId), url, rest[5]?.qualities || null); - } - return result; -} - /** @type {Array<'requestHeaders'|'extraHeaders'|'blocking'>} */ const webRequestPerms = ['requestHeaders']; // Detection reads a page load's Content-Type, to tell an HTML page from a stream. diff --git a/tests/unit/UrlMatchList.test.mjs b/tests/unit/UrlMatchList.test.mjs index 882b0bdb..1b65906d 100644 --- a/tests/unit/UrlMatchList.test.mjs +++ b/tests/unit/UrlMatchList.test.mjs @@ -304,8 +304,10 @@ describe('domainEntriesExclude (the Auto-enable URLs list)', () => { // Two lists share this syntax. background.mjs once had its own parser for the Auto-enable // URLs list, and it drifted: it dropped -domain lines and accepted a lone `~`. Both lists // now go through this class; this keeps a second parser from coming back. -describe('background.mjs', () => { - const source = fs.readFileSync(path.resolve(import.meta.dirname, '../../chrome/background/background.mjs'), 'utf8'); +// The MPV allowlist went to MpvMode.mjs with the rest of MPV mode (2026-11-07). +describe('background.mjs and MpvMode.mjs', () => { + const source = ['background.mjs', 'MpvMode.mjs'].map((name) => + fs.readFileSync(path.resolve(import.meta.dirname, '../../chrome/background/' + name), 'utf8')).join('\n'); it('builds both URL lists from UrlMatchList', () => { expect(source).toMatch(/const AutoEnableList = new UrlMatchList\(\{domainEntriesExclude: true\}\);/); diff --git a/tests/unit/typeChecked.test.mjs b/tests/unit/typeChecked.test.mjs index 196a888d..cd5586d4 100644 --- a/tests/unit/typeChecked.test.mjs +++ b/tests/unit/typeChecked.test.mjs @@ -20,6 +20,7 @@ const CHECKED = [ 'chrome/background/KeyShortcut.mjs', 'chrome/background/ManifestTypes.mjs', 'chrome/background/MpvBackend.mjs', + 'chrome/background/MpvMode.mjs', 'chrome/background/MpvRelayWatch.mjs', 'chrome/background/MpvSubtitles.mjs', 'chrome/background/MpvTarget.mjs',