Skip to content

Player: five robustness fixes in source setup, seeking and the progress loop - #567

Merged
Andrews54757 merged 5 commits into
Andrews54757:mainfrom
Nawid3333:upstream-pr/player-robustness
Oct 9, 2026
Merged

Andrews54757 merged 5 commits into
Andrews54757:mainfrom
Nawid3333:upstream-pr/player-robustness

Conversation

@Nawid3333

Copy link
Copy Markdown
Contributor

Five small, independent fixes in the player core. Each one is its own commit
and applies to main alone, or with any of the others left out, so any of
them can be dropped. Line numbers are for current main.

What breaks

1. A failed seek preview aborts the video's setup. setSourceInternal()
awaits setupPreviewPlayer() without a catch (FastStreamClient.mjs:948).
If the preview fails to build, the main player is already loading, but
setup skips everything after that line: the video analyzer, the CSS
filters, autoplay, and the timestamp/saved-progress restore. The user also
gets the "send to developer" alert for a video that loads fine.
updateOptions() already treats the same call as optional (:366-368).

2. Seeks outside the video throw away the MP4 buffer. The seek keys
(Comma/Period, Shift+arrows, the arrows) and the skip buttons do
client.currentTime += step. Near either end, that asks for a time outside
the video: Comma (10 s back) at 4 s asks for -6 s. The media element clamps
the seek itself, but the client's setter passes -6 to the state, to the
synced audio player and to the player. MP4Player's setter then asks
whether -6 s is buffered. It never is, so resetHLS() drops the whole MSE
buffer, although the seek lands on 0, which was buffered. The same happens
past the end.

3. Undo/redo leaves the separate audio track behind. undoSeek() and
redoSeek() set this.player.currentTime directly. That skips the client's
setter, which is what also seeks the synced audio player (used with a video
delay). The audio stays at the old position until watcherLoop() resyncs
it, which can take up to about a minute once its resync allowance is used
up.

4. A video that never loaded passes its timestamp to the next one. The
restore at the end of setSourceInternal() waits on this.initPromise,
which resolves when whichever player is current is ready. resetPlayer()
does not clear it. So if source A never loads, A's chain is still waiting
when source B is set, and when B is ready it seeks B to A's
faststream-timestamp.

5. The progress loop can stay stopped. stopProgressLoop() only clears
shouldRunProgressLoop, and the loop notices on its next frame. A
runProgressLoop() before that frame sees isRunningProgressLoop still
true and returns without setting the flag back, so the pending frame stops
the loop. The seek bar then only moves on timeupdate (about 4 Hz). For
example, the next video loads in a background tab: reset() stops the loop,
and the new video's durationChanged() asks it to run again.

The fix

  1. The preview build's rejection is caught and logged with
    console.error. When it fails, previewPlayer stays null, as with
    previews turned off.
  2. The client's setter ignores NaN and clamps to [0, duration] (only the
    lower bound when the duration is unknown or infinite). MP4Player still
    assigns the raw value to the element, but checks isBuffered() against
    the time the element actually seeks to.
  3. Undo/redo go through this.currentTime, with seek saving off, the same
    pattern the other internal seeks use.
  4. The restore chain returns if this.source !== source, checked both
    before and after the wait. The check after the wait is the fix. The one
    before it keeps a chain that starts late from setting
    disableProgressSave for the next source, and then leaving it set.
  5. runProgressLoop() sets shouldRunProgressLoop = true before the
    isRunningProgressLoop guard.

Diff: 3 files, 31 insertions, 5 deletions.

Why this can't break anything else

  1. Only the rejection path changes. A preview that builds runs as before.
  2. A value already in [0, duration] passes through unchanged, and the
    element already clamped the rest, so where the video ends up doesn't
    change. Now the state, the audio track and MP4Player's buffer check agree
    with it. A seek to an unbuffered time inside the video still resets.
  3. The player gets the same assignment as before. With seek saving off,
    pastSeeks/pastUnseeks end up exactly as before.
  4. For a source that is still current, both checks pass. The new source's
    own chain still does its own restore.
  5. The guard still prevents a second loop, and a stop with no run after it
    still stops the loop.

Verification

Not run in Chrome or Firefox. A Node script took the real method bodies from
main and from this branch. It ran them against stubbed players, a fake
media element (with spec seek clamping) and a manual animation-frame queue.
For fixes 1 and 4, it ran the real setSource()/setSourceInternal(),
setupInitHook(), the preview build and loadProgressData():

case main this branch
1. MP4 with faststream-timestamp=4, preview build rejects alert, no analyzer, stays at 0 no alert, analyzer set, seeks to 4
2. skip back 10 s at 4 s state -6, audio -6, buffer reset 0, 0, no reset
2. skip forward 10 s at 158 s of 160 state 168, buffer reset 160, no reset
2. currentTime = NaN TypeError, state NaN ignored
2. control: seek to unbuffered 90 s reset reset
3. seek 10 → 50, undo, redo audio seeks [50] [50, 10, 50], same seek history
4. dead HLS with timestamp 8, then an MP4 MP4 ends at 8 MP4 stays at 0
5. stop + run before the next frame 0 updates in 5 frames 5 updates
5. control: plain stop stops stops
  • One commit per fix: the script run at each commit changes only that
    commit's rows. Each commit applies to main alone, and the rest apply
    with any one left out (git apply --check).
  • Build: npm ci + npm run build: all three targets build.
  • Lint: eslint with this repo's .eslintrc.js: no problems.

🤖 Generated with Claude Code

Nawid3333 and others added 5 commits September 29, 2026 12:48
setSourceInternal() awaited setupPreviewPlayer() without a catch, so if
the preview player failed to build, the error jumped to the outer catch:
the video analyzer, CSS filters, autoplay and the time/progress restore
were all skipped, and the "send to developer" alert came up for a video
that loads fine. updateOptions() already catches the same call; do the
same here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The arrow keys, Comma/Period, Shift+arrows and the skip buttons are
relative seeks (currentTime += step), so near either end they ask for a
time outside the video: Comma at 4 s asks for -6 s. The media element
clamps that itself, but the client's setter passed the raw value on to
state.currentTime, the separate audio track and the player. MP4Player
then checked whether -6 s was buffered, which it never is, and called
resetHLS(), throwing away the whole buffer for a seek that lands on the
already-buffered 0.

The client setter now clamps to [0, duration] (only the lower bound when
the duration is unknown or Infinity) and ignores NaN, which used to throw
from video.currentTime. MP4Player checks the clamped target too, for
callers that set its currentTime directly, like the synced audio player.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
undoSeek() and redoSeek() set this.player.currentTime directly, skipping
the client's setter, which is what also seeks the synced audio player
(the separate audio track used with a positive video delay, or any delay
without Web Audio). The audio stayed at the old position until the
once-a-second watcher resynced it, or up to a minute longer if the
watcher had used up its resync allowance, which the setter also resets.
Go through the setter, with seek saving off as the other internal seeks
do, so the undo itself is not recorded as a seek.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The progress/timestamp restore at the end of setSourceInternal() waits
on this.initPromise, which resolves once whatever player is current has
a duration and a frame. resetPlayer() does not clear it, so if a source
never gets that far (a dead stream, or one replaced before it loads),
its wait carries over to the next source and ends when that one is
ready. The old chain then seeks the new video to the old source's
faststream-timestamp and may call play() for it. Bail out when the
source has changed, both before and after the wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stopProgressLoop() only clears shouldRunProgressLoop; the loop notices
on its next animation frame. A runProgressLoop() before that frame saw
isRunningProgressLoop still true and returned without setting
shouldRunProgressLoop back, so the pending frame stopped the loop even
though the last call asked it to run. The seek bar then only moved on
timeupdate (about 4 Hz) until something called runProgressLoop() again.
rAF is paused in a hidden tab, so e.g. reset() and the new video's
durationChanged() both land in that gap when the next video loads in
the background. Set the flag before the guard.

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.

2 participants