Skip to content

feat(detection): play the stream the page's own video played - #67

Merged
Nawid3333 merged 4 commits into
mainfrom
claude/stream-pick
Sep 30, 2026
Merged

Nawid3333 merged 4 commits into
mainfrom
claude/stream-pick

Conversation

@Nawid3333

@Nawid3333 Nawid3333 commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Of a page's streams, the player has played the longest since #50. Now it first asks what the page's <video> that it replaced was playing, and plays that stream. This fixes three kinds of wrong pick:

  • A page loads the next episode, or another longer stream, beside the video. Today the longer one plays. Now the stream the video was playing plays.
  • Several players on one page. Today the page's longest stream wins. Now the player plays what its own replaced video played.
  • An ad whose length can't be read, beside a readable video of three minutes or more. An unknown length counts as 10 minutes, so today the ad can win. Now the video's own stream plays.

When the page's video could be an ad, a trailer or a preview, the longest still decides, as before. The player may wait a moment longer, for one message round trip to the page (at most 1 s), and only when there is more than one stream.

This changes which stream plays. As you decided tonight, it merges on green CI, and you test the release in your own Firefox later.

Why

You asked for a better way than length to pick the stream you want, and chose the "never worse" version. In any case where the page's video could be an ad, a trailer or a preview, the old rule (the longest) decides.

This builds on #61 and #66 (a piece of a stream still being read no longer plays), both merged. Both change the same two lines in background.mjs, where the lengths are taken.

The rule

Before it picks, the player asks what the page's <video> that it replaced was playing:

  • The video plays a detected file: that file.
  • Otherwise: the stream whose measured length matches the video's length, within 3 s or 0.25%. An MSE player (hls.js, Shaka, dash.js) sets its video's length from the manifest.

The never-worse part. The old rule (the longest) decides instead:

  • when a stream five times as long or more plays, or a live one;
  • when the video runs under three minutes and any longer stream plays. Ads, trailers and previews run a few minutes at most. A stream as long as the video, within the match's tolerance (another rendition), doesn't count as longer.

By file:

  • chrome/player/utils/StreamPick.mjs: the rule. The player and the mpv pick share it.
  • chrome/content.js:
    • It records the replaced video's currentSrc and length when it replaces it.
    • It answers GET_PLAYED_VIDEO for the player that asks. It sends the video's current values while the video still plays the same thing, and otherwise the recorded ones.
    • A replaced box that holds several videos gives no hint.
  • chrome/background/background.mjs:
    • sendSources asks the page after the lengths are read, then waits only for sources detected in the meantime. It waits at most 1 s for the answer: content scripts share the page's event loop, and a page that keeps it busy would otherwise hold the player without sources.
    • The question carries the asking player's frameId. Once a player's iframe links up, its data moves from replacedPlayerQueue to iframeMap. The first version only looked in the queue and so never got an answer; the e2e caught this.
    • The mpv pick passes along the video that the user started.

What stays as before

The old rule decides in these cases, on purpose:

  • a video under three minutes, beside any longer stream;
  • any video beside a stream five times as long or more;
  • any finite video beside a live stream (live counts as endless);
  • no video found (the player fills the page);
  • a box with several videos;
  • a length that matches no stream.

Tests

  • Unit, tests/unit/StreamPick.test.mjs: 15 cases. 17 of 17 mutants of StreamPick.mjs fail them, each removing or shifting one part: the 3-minute and 5x limits, the match tolerance, the live rule, and the others.

  • e2e, tests/e2e/ext-specs/source-length.e2e.mjs, 3 new cases:

    • a 320-second film in the page's video, with a 12-minute stream beside it: the film plays;
    • an MSE video whose length is set on its MediaSource, as hls.js does: the 4.5-minute stream of that length plays, not the 12-minute one;
    • a 10-second ad in the page's video, beside the 12-minute stream: the stream plays.

    The existing 5 cases still pass. On a build where the page's answer never arrived, the first two new cases failed: they test the new rule.

  • Review: GLM 5.3 reviewed the design and the code three times. Its findings reshaped the rule:

    • the limits apply at every length;
    • a video under three minutes never beats a longer stream;
    • no hint comes from a box with several videos;
    • the video is recorded when it is replaced;
    • the second wait covers only new sources;
    • the mid-roll rule is gone (below);
    • a fourth review, of this branch rebased onto fix(detection): a piece whose length is still being read no longer plays #66, found that the page's answer had no time limit. It now waits at most 1 s. Checked on a build whose content.js never answers: every case of the source-length spec got its sources, and only the two that need the answer failed, playing the longest.
  • Local, on fix(detection): a piece whose length is still being read no longer plays #66: lint, typecheck, 638 unit tests, the build, and the whole source-length spec in Firefox 156: 8 of 8 pass. That run was before the 1 s cap; CI runs the spec with it.

  • The classic e2e specs (test:ext, 9 files): in the first run on this branch, mpv-suspend failed twice with a hand-off of the same page. It passed 2 of 2 on its own, and then the whole set passed 9 of 9 on this branch and on main, run back to back. This PR leaves alone the code that stops a second hand-off of a page, which is what that spec tests, so it was a flaky run.

Not in this PR

  • Pages that have requested only the ad so far (cineby: 4 servers). Fixing these needs waiting for a longer stream, a separate idea.
  • A mid-roll rule, which used the video's length from before an ad. It was dropped during review: GLM found it could pick the previous episode on sites that reuse one video element across episodes.

🤖 Generated with Claude Code

@Nawid3333 Nawid3333 self-assigned this Sep 29, 2026
Nawid3333 and others added 4 commits September 30, 2026 00:21
Of a page's streams, the player played the longest. Now it first asks what
the page's <video> that it replaced was playing: its file, when the video
plays a detected file, or else the stream whose measured length matches the
video's (within 3 s or 0.25%), as an MSE player sets it from the manifest.
A page that loads the next episode, or another longer stream, beside the
video no longer gets that one played instead.

Never over a far longer stream: when a stream five times as long plays
(or a live one), the video is an ad, a trailer or a preview as likely as
not, and the longest decides, as before. So does a video that tells
nothing: no video (the player fills the page), a box with several videos,
or a length that matches no stream.

- content.js: snapshots the replaced video's currentSrc and duration, and
  answers GET_PLAYED_VIDEO for the asking player (its iframeMap entry, or
  the queue before it linked up) with the live values while the video still
  plays the same thing.
- background.mjs: sendSources asks after the lengths are read, then waits
  for the sources detected meanwhile only. The mpv pick passes the played
  video from the user's play event.
- StreamPick.mjs: the rule, shared by the player and the mpv pick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unread length

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GLM's review of the pick: a 30-second pre-roll in the page's video, before a
2:10 clip, played the ad. The clip is not five times as long, so the 5x
guard kept the ad. Ads, trailers and previews run a few minutes at most:
a video under three minutes now chooses only among streams no longer than
it (as long, within the match's tolerance, counts: another rendition). At
three minutes or more, the 5x guard as before.

The e2e film is now the fixture played twice over (320 s), and the MSE clip
4.5 minutes, both over three minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Content scripts share the page's event loop, so a page that keeps its
scripts busy answered GET_PLAYED_VIDEO late, and one that never lets go
held the player without sources. Before stream-pick, sendSources never
waited on the page. Now the longest decides after 1 s, as before the
question. GLM 5.3's review of the rebased branch.

Checked on a build whose content.js never answers: the source-length
spec's players all got their sources; only the two cases that need the
answer failed, playing the longest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Nawid3333

Copy link
Copy Markdown
Owner Author

On hold (your call, 2026-09-30). Tonight's batch went in without this PR. It merges on its own once the failure below is explained.

What failed. CI run 36639172303 at ef32b6e: e2e (Windows) failed classic-specs/mpv-shortcut.e2e.mjs in both attempts. The two attempts failed in different places:

  • Attempt 1: "takes an open in-page player over to mpv, then waits for a play" — mpv never got the reloaded page's video. The mpv helper's log (artifact e2e-logs-windows) has no open request for it at all (clip.mp4?t=…100682), so the extension never sent the hand-off. It wasn't mpv or the runner.
  • Attempt 2: "switches between MPV and the in-page player by their keys alone" — the in-page player never appeared after the second Ctrl+Shift+F.

Every later test in the file failed only because the tab was left in the wrong mode.

Against it being this PR:

  • The rerun of the same commit passed: 80 of 80 spec files, no retries. It ran on runner image 20260922.246.2; the failed run was on 20260925.250.1.
  • Locally, the spec passed 3 of 3 (Firefox 156).
  • The spec has failed its first attempt 4 times in the last ~40 CI runs without this PR, each time in "arms MPV on a blank tab", and passed on the retry.

For it being this PR: both failures follow a switch between the in-page player and mpv with a page reload, and this PR makes the in-page player wait longer for its sources: up to 1 s for the page's answer, plus up to 2.5 s for sources found meanwhile. GLM 5.3 flash looked for a mechanism and found none that the code supports; its guess is a race that the longer wait widens.

Next:

  • Run mpv-shortcut many times on CI, on this branch and on main.
  • Add logging at onUserPlay and openPlayer, to see whether the play is dropped at the MPV-arm check, at the source lookup, or never arrives.

@Nawid3333
Nawid3333 merged commit 311ff36 into main Sep 30, 2026
12 of 13 checks passed
@Nawid3333
Nawid3333 deleted the claude/stream-pick branch September 30, 2026 03:00
Nawid3333 added a commit that referenced this pull request Sep 30, 2026
…player of the new page (#79)

Ctrl+Shift+U with the in-page player just opened reloads the page
(startMpv). A player still starting then could say it loaded
(PLAYER_LOADED) after the new page's FRAME_ADDED: its beforeunload, which
sends FRAME_REMOVED, is only added once it has loaded, and the dying
out-of-process iframe kept running. The background took it for a player
of the new page, so frame 0 had a player: onHeadersReceived dropped every
stream the page requested as the player's own, and openPlayer refused,
until the page navigated again. mpv got nothing on a play, and
Ctrl+Shift+F opened nothing.

That is main's red CI (run 36662468457, e2e Windows, mpv-shortcut): the
takeover test's play reached no mpv, and on the retry the key-switch
test's second Ctrl+Shift+F showed no player. The CDN saw no length probe
of the reloaded page's streams, which every detection starts while the
tab is on: they were never detected. The same two failures held #67's
own run. Since #67 the spec failed so in 4 of 5 attempts, and in none of
~40 runs before; locally it never did (the key lands before the player
loads, or after it has added its beforeunload).

- content.js names its page (DocumentKey; crypto.getRandomValues, as
  randomUUID needs a secure context), in FRAME_ADDED and as the player
  URL's opener.
- TabHolder.isPlayerOfGoneDocument: the opener is neither the page in
  the player's own frame (the redirect) nor the one in the frame above
  (an iframe). A frame whose page never named itself to this background
  (it restarted since, or the new page's FRAME_ADDED has not come yet)
  proves nothing, and the player counts.
- background.mjs answers such a PLAYER_LOADED with null and forgets its
  frame.
- mpv-shortcut: a page holding a player that names a gone page, then
  its own video: the toolbar opens a player for it. On main it fails,
  and the background trace shows the state CI had (the player attached
  to frame 0 after FRAME_ADDED, the clip dropped by hasPlayer); here it
  passes, with the whole spec (12/12). The unit tests fail on each of 5
  mutations of the check.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant