Repository navigation
Player: five robustness fixes in source setup, seeking and the progress loop - #567
Merged
Andrews54757 merged 5 commits intoOct 9, 2026
Merged
Conversation
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>
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, independent fixes in the player core. Each one is its own commit
and applies to
mainalone, or with any of the others left out, so any ofthem 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 outsidethe 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 askswhether -6 s is buffered. It never is, so
resetHLS()drops the whole MSEbuffer, 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()andredoSeek()setthis.player.currentTimedirectly. That skips the client'ssetter, which is what also seeks the synced audio player (used with a video
delay). The audio stays at the old position until
watcherLoop()resyncsit, 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 onthis.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 clearsshouldRunProgressLoop, and the loop notices on its next frame. ArunProgressLoop()before that frame seesisRunningProgressLoopstilltrue 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). Forexample, 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
console.error. When it fails,previewPlayerstaysnull, as withpreviews turned off.
NaNand clamps to[0, duration](only thelower bound when the duration is unknown or infinite).
MP4Playerstillassigns the raw value to the element, but checks
isBuffered()againstthe time the element actually seeks to.
this.currentTime, with seek saving off, the samepattern the other internal seeks use.
this.source !== source, checked bothbefore and after the wait. The check after the wait is the fix. The one
before it keeps a chain that starts late from setting
disableProgressSavefor the next source, and then leaving it set.runProgressLoop()setsshouldRunProgressLoop = truebefore theisRunningProgressLoopguard.Diff: 3 files, 31 insertions, 5 deletions.
Why this can't break anything else
[0, duration]passes through unchanged, and theelement 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.
pastSeeks/pastUnseeksend up exactly as before.own chain still does its own restore.
still stops the loop.
Verification
Not run in Chrome or Firefox. A Node script took the real method bodies from
mainand from this branch. It ran them against stubbed players, a fakemedia 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 andloadProgressData():faststream-timestamp=4, preview build rejectscurrentTime = NaNTypeError, state NaN[50][50, 10, 50], same seek historycommit's rows. Each commit applies to
mainalone, and the rest applywith any one left out (
git apply --check).npm ci+npm run build: all three targets build.eslintwith this repo's.eslintrc.js: no problems.🤖 Generated with Claude Code