Repository navigation
Player opening, custom source patterns and request(): five small fixes - #569
Merged
Andrews54757 merged 7 commits intoOct 9, 2026
Merged
Conversation
openPlayer() set frame.playerOpening and cleared it again only on a
'no_video' answer. When the message failed instead (no content script in
the frame, or content.js never answered), the answer is undefined and the
flag stayed set; openPlayer() returns early for such a frame, so it never
got a player again until it navigated.
Keep the flag only for the three answers that mean a player is on its way
('redirect', 'replaceall', 'replace'); PLAYER_LOADED clears it as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A toolbar click that turns a tab on measures the page's videos (GET_VIDEO_SIZE), then sends OPEN_PLAYER, and the player announces itself with PLAYER_LOADED. Off only reloaded the page when a frame had isPlayer set, so an Off clicked before PLAYER_LOADED left the player on the page with the toolbar showing off: - during the measuring, openPlayersWithSources() went on to open the player after the await although the tab was off by then; - after OPEN_PLAYER, the Off path did not look at playerOpening. Off now counts a frame with playerOpening as having a player, and openPlayersWithSources() stops if the tab was turned off while it measured. Relies on the previous commit: without it, a frame whose OPEN_PLAYER failed kept playerOpening and Off would reload a page that has no player. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a video fills its frame and the frame may not go fullscreen (an iframe without allowfullscreen), handlePlayerOpen() sends the frame itself to the player page. That navigation fires beforeunload, which sends FRAME_REMOVED, and the background resets the frame, drops its sources and forgets it. The player then loads in the same frame and asks for its sources (REQUEST_SOURCES) and gets none: it stays on "Welcome to FastStream". Skip FRAME_REMOVED for the redirect to the player. The frame is not going away; PLAYER_LOADED and REQUEST_SOURCES come from it next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A custom source pattern line with an extension and no /regex/ (a lone "hls") parses to the empty regex. It is joined in as "()", which matches the empty string at the start of every URL, so match() found a match with no non-empty group and returned undefined: every other pattern with the same flags stopped matching, silently. addRegex() now throws for it, as it does for an invalid regex; loadCustomPatternsFile() logs the warning and skips the line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
match() used str.match(regex). For a regex with the g flag that returns every match and no groups, so the output lookup found nothing and the pattern never matched. With y, the compiled regex kept lastIndex from the previous call, so a pattern that matched one URL failed on the next. Use regex.exec() with lastIndex reset to 0, which returns the groups of the first match whatever the flags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compile() wraps each output's patterns in a plain group, joins them with |, and match() took the output from the position of the first non-empty group. A capture group inside a user's own pattern shifts every later group, so a match for a later output landed on the wrong index: with "m3u8 /(foo|bar)\.php/" and "mp4 /baz/", a URL containing baz returned undefined and was not treated as a source. A pattern whose match is empty (a lookahead) also returned undefined, as its group was ''. Wrap each output in a named group (__fsOutput0, 1, ...) and return the output whose group took part in the match. Needs the two previous commits: exec() for the groups of a g regex, and the empty-regex check, since an empty pattern would now match every URL instead of disabling the others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
request() builds the XHR inside an async Promise executor. A throw in there (xhr.open() on a URL the parser rejects, setRequestHeader() with an invalid header, a rejected SET_HEADERS message) only rejects the executor's own promise, which nobody sees; the promise request() returns stays pending forever and requestSimple()'s try/catch never runs. In the subtitles menu, "From URL" with a URL like https://example.com:99999/a.vtt (accepted by the dialog, refused by the URL parser) showed "Downloading" and nothing after. Wrap the executor's body in try/catch and reject with the error. Whitespace aside, the change is the four lines of the try/catch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Five small fixes in how FastStream opens the player over a page, matches
custom source patterns, and makes requests. There are seven commits, and any
of them can be dropped, except that 2 goes after 1, and 4 is three commits
that go together (reasons below). With whitespace ignored, the diff is +38/-7.
What breaks
1. A frame whose OPEN_PLAYER fails never gets a player again.
openPlayer()setsframe.playerOpeningand skips any frame that has it.The reply clears it only on
'no_video'(background.mjs:1234). When themessage fails, because the frame has no content script or content.js throws
before replying, the reply is
undefinedand the flag stays set. Afterthat, no click or new source opens a player in that frame until it loads a
new document.
2. Toolbar Off doesn't remove a player that is still opening. Off
reloads the page only if a frame has
isPlayer, which PLAYER_LOADED sets.An Off before that leaves the player on a page whose toolbar says off:
openPlayersWithSources()is still measuring the videos (anawaiton content.js): the player is opened afterwards anyway;playerOpeningis set, and Off doesn't look at it.3. A frame sent to the player page plays nothing. When a video fills an
iframe that may not go fullscreen,
handlePlayerOpen()navigates the frameitself to the player (
content.js:255). That fires the page'sbeforeunload, which sends FRAME_REMOVED, and the background drops theframe and its sources. The player then loads in the same frame, asks for
sources, finds none, and stays on "Welcome to FastStream".
4. Custom source patterns match the wrong type, or nothing.
MultiRegexMatcherjoins the patterns into(a|b)|(c)and picks the outputfrom the position of the first non-empty group. Three things go wrong with
patterns a user can write:
hlswith no/regex/) becomes the empty regex.It matches at the start of every URL with an empty group, so
match()returns
undefined. Every other pattern with the same flags silentlystops matching.
g,str.match()returns no groups, so the pattern nevermatches. With
y,lastIndexcarries over, so the pattern alternatesbetween matching and not.
with
m3u8 /(foo|bar)\.php/andmp4 /baz/, abazURL returnsundefined. A lookahead-only pattern is lost too, since its group is''.5.
RequestUtils.request()never settles if anything throws beforesend(). The XHR is set up in anasyncPromise executor, so a throw fromopen(),setRequestHeader()or the SET_HEADERS message only rejects apromise nobody sees. For example, Subtitles → "From URL" accepts
https://example.com:99999/sub.vtt, the URL parser rejects it, and thetoast says "Downloading" forever.
The fix
playerOpeningonly for'redirect','replaceall'and'replace', the answers that mean a player is coming. Anything else,including no answer, clears it.
playerOpening, andopenPlayersWithSources()returns if the tab was turned off during themeasuring. (2 needs 1: otherwise a frame whose OPEN_PLAYER failed would
keep the flag, and Off would reload a page with no player.)
handlePlayerOpen()setsRedirectingToPlayerjust before the redirect,and
beforeunloadskips FRAME_REMOVED when it is set. The frame isn'tgoing away: the player takes it over, and finds the frame's sources.
addRegex()refuses the empty regex, as it already refuses aninvalid one.
loadCustomPatternsFile()catches that, warns, and skipsthe line.
match()usesregex.lastIndex = 0; regex.exec(str), which isthe same as
str.match()withoutg/y, and correct with them.(?<__fsOutputN>...), andmatch()returns the output whose grouptook part (
!== undefined), ignoring the user's own groups. c needsb, for the groups of a
gregex. It also needs a: without it, an emptypattern would now route every URL to its type.
try { ... } catch (e) { reject(e); }.The rest is only re-indented (
git diff -w: +4).Why this can't break anything else
are unchanged. Only
undefined(a failure) now clears the flag.playerOpeningis set only while a player is on its way,so reloading it away is what Off already does for a loaded player.
openPlayer()'s own guard still stops a frame from being opened twiceafter a quick On → Off → On.
still sends FRAME_REMOVED, and nested frames still report themselves.
g/y, without groups of their own, and notmatching the empty string give the same results. Named groups need
Chrome 64 / Firefox 78, and the manifests ask for 114 / 113.
catchonly runs when setup throws, before any listener canresolve.
requestSimple()already catches a rejection and calls back withthe error, and the other callers already handle a failed request.
Chrome: 1, 2 and 4 use the same in-memory background state as the code
around them. 3 assumes the iframe keeps its
frameIdwhen it navigates tothe extension page. I haven't checked that on Chrome. If it doesn't, the
player gets no sources, as today.
Verification
Not run in Chrome or Firefox. Node scripts ran the real code from
mainandfrom this branch:
text, with the real
TabTrackerand a stubbedchrome.tabs;XMLHttpRequeststubbed for 5.'replace'answer[]["https://cdn.test/v.m3u8"]hlsalone +mp4 /baz/, URL…/bazundefinedmp4(and a warning)mp4 /baz/g, same URL twiceundefined,undefinedmp4,mp4mp4 /https:/y, same URL three timesmp4,null,mp4mp4×3m3u8 /(foo|bar)\.php/+mp4 /baz/, URL…/bazundefinedmp4m3u8 /(?=.*master)/, URL…/master.txtundefinedm3u8open()throws2 applied without 1, "On, OPEN_PLAYER fails, Off" reloads a page that has
no player.
npm ci+npm run build: all targets build, with every changein each zip.
eslintat each of the seven commits: no problems.background.mjs.🤖 Generated with Claude Code