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/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/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..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'; @@ -16,32 +16,31 @@ 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 {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'; +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(); @@ -2192,54 +1973,12 @@ 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 */ /** @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. @@ -2412,13 +2151,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, @@ -2537,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); @@ -2549,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 @@ -2575,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 @@ -2682,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; @@ -2696,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; } @@ -2711,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. @@ -2772,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) => { @@ -3168,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; } @@ -3184,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. @@ -3362,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; @@ -3400,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) => { @@ -3552,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 @@ -3578,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) => { @@ -3594,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, @@ -3959,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; @@ -4322,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]}))) @@ -4374,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. @@ -4494,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); @@ -4522,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 @@ -4550,132 +3375,12 @@ 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. /** @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 +3429,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/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 3950b655..cd5586d4 100644 --- a/tests/unit/typeChecked.test.mjs +++ b/tests/unit/typeChecked.test.mjs @@ -20,11 +20,13 @@ 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', 'chrome/background/MultiRegexMatcher.mjs', 'chrome/background/NetRequestRuleManager.mjs', + 'chrome/background/StreamDetection.mjs', 'chrome/background/StreamLengths.mjs', 'chrome/background/TabTracker.mjs', 'chrome/background/UrlMatchList.mjs',