Skip to content

fix(spf): load final segment when seeking to exact end (#1828) - #1852

Merged
cjpillsbury merged 2 commits into
mainfrom
fix/spf-exact-end-seek-stall
Jul 28, 2026
Merged

fix(spf): load final segment when seeking to exact end (#1828)#1852
cjpillsbury merged 2 commits into
mainfrom
fix/spf-exact-end-seek-stall

Conversation

@cjpillsbury

@cjpillsbury cjpillsbury commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1828.

Summary

Seeking to currentTime === duration exactly (while playing) stalled indefinitely — the final segment was never selected by the forward-load window, so endOfStream() never fired and the seek couldn't complete.

Root cause

getSegmentsToLoad filtered segments with a strict endTime > currentTime overlap test. At currentTime === duration the final segment's endTime === duration, so it was excluded, never loaded, and the MediaSource stayed open (readyState 1, seeking stuck true).

Fix

Include the final segment at the end boundary (isLast && endTime >= currentTime) in getSegmentsToLoad; interior boundaries keep strict > so a just-finished segment isn't reloaded. (packages/spf/src/media/buffer/forward-buffer.ts)

Smoke test

Deploy preview (this PR's Vercel branch build), looping so the end boundary is exercised:
https://v10-sandbox-git-fix-spf-exact-end-seek-stall-mux.vercel.app/spf-segment-loading/?muted=true&autoplay=true&loop=true&preload=auto

Watch it reach the end (drag the scrubber near the end to get there quickly). Confirm across Chromium, Firefox, and Safari/WebKit:

  • With loop=true (the link above): the stream reaches the end and restarts cleanly from the beginning — no stall at the boundary.
  • Remove &loop=true: the stream reaches the end and stops cleanly at ended (not frozen mid-seek), and pressing play again restarts from the start.

Pre-fix, the stream instead stalled at the very end — the final segment never loaded, so it neither ended nor looped. Any VOD exercises this (a general segment-loader edge, not relocation-specific); the page default is a Mux VOD.

Testing

  • Unit: regression test (exact-end → loads final segment; fails before the fix) + guard test (mid-stream boundary does not re-select the finished segment). forward-buffer 21/21, segment-loader actor 5/5, typecheck + biome clean.
  • Browser smoke (sandbox /spf-segment-loading/, Mux VOD, seek to exact duration while playing): reaches ended in ~0.5s — last segment loads, MediaSourceended, buffered extends to the full end. On the same harness pre-fix: stalls (seeking stuck true, MediaSource open, buffered never reaches the end).

Notes

  • General segment-loader edge — reproduces on native 0-based sources, so it is not caused by the non-zero-PTS relocation work.
  • Branched off feat/spf-non-zero-pts-relocation and targets it for a clean diff, but touches only pre-existing code, so it's cleanly cherry-pickable to main if it should land independently.

🤖 Generated with Claude Code


Note

Medium Risk
Touches core forward-buffer segment selection used by the segment loader; behavior change is narrow (terminal segment only) but affects end-of-VOD and loop seeks across all SPF playback.

Overview
Fixes #1828 by changing how getSegmentsToLoad decides whether a segment overlaps the playhead. Interior segments still require segmentEnd > currentTime so a segment you just finished at a boundary is not re-fetched; the last segment is always treated as overlapping when it falls in the forward window, so seeks to currentTime === duration (and slight overshoot after loop/MediaSource.duration clamping) still queue the final segment for load.

Without that, the last segment was dropped at the exact end, endOfStream() never ran, and playback could stall with seeking stuck. Tests were updated for the “past all segments” case and new exact-end regressions plus a mid-stream boundary guard.

Reviewed by Cursor Bugbot for commit 1ef1590. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v10-sandbox Ready Ready Preview, Comment Jul 27, 2026 9:35pm

Request Review

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size Report

🎨 @videojs/html — no changes
Presets (7)
Entry Initial Lazy
/video (default) 51.12 kB 54.24 kB
/video (default + hls) 190.40 kB 54.24 kB
/video (minimal) 51.11 kB 54.24 kB
/video (minimal + hls) 190.57 kB 54.24 kB
/audio (default) 44.42 kB 54.24 kB
/audio (minimal) 41.63 kB 54.24 kB
/background 4.20 kB
Media (10)
Entry Initial
/media/background-video 1.14 kB
/media/container 1.71 kB
/media/dash-video 214.79 kB
/media/hlsjs-video 141.52 kB
/media/mux-audio 164.00 kB
/media/mux-video 163.70 kB
/media/native-hls-video 9.05 kB
/media/simple-hls-audio-only 19.13 kB
/media/simple-hls-video 21.11 kB
/media/vimeo-video 12.31 kB
Players (5)
Entry Initial
/video/player 8.22 kB
/audio/player 5.38 kB
/background/player 3.92 kB
/live-video/player 7.63 kB
/live-audio/player 5.39 kB
Skins (30)
Entry Type Initial Lazy
/video/minimal-skin.css css 5.70 kB
/video/skin.css css 5.65 kB
/video/minimal-skin js 51.10 kB 54.24 kB
/video/minimal-skin.tailwind js 51.87 kB 54.24 kB
/video/skin js 51.12 kB 54.24 kB
/video/skin.tailwind js 51.87 kB 54.24 kB
/audio/minimal-skin.css css 3.97 kB
/audio/skin.css css 3.86 kB
/audio/minimal-skin js 41.62 kB 54.24 kB
/audio/minimal-skin.tailwind js 42.20 kB 54.24 kB
/audio/skin js 44.42 kB 54.24 kB
/audio/skin.tailwind js 44.91 kB 54.24 kB
/background/skin.css css 133 B
/background/skin js 1.14 kB
/live-video/minimal-skin.css css 5.70 kB
/live-video/skin.css css 5.65 kB
/live-video/minimal-skin js 48.06 kB 54.24 kB
/live-video/minimal-skin.tailwind js 48.56 kB 54.24 kB
/live-video/skin js 49.60 kB 54.24 kB
/live-video/skin.tailwind js 50.22 kB 54.24 kB
/live-audio/minimal-skin.css css 3.97 kB
/live-audio/skin.css css 3.86 kB
/live-audio/minimal-skin js 33.99 kB 54.24 kB
/live-audio/minimal-skin.tailwind js 33.42 kB 54.24 kB
/live-audio/skin js 36.91 kB 54.24 kB
/live-audio/skin.tailwind js 36.40 kB 54.24 kB
/global.css css 183 B
/shared.css css 153 B
/tailwind.css css 161 B
/skin-element js 1.51 kB
UI Components (39)
Entry Initial
/ui/airplay-button 2.57 kB
/ui/alert-dialog 2.95 kB
/ui/alert-dialog-close 2.39 kB
/ui/alert-dialog-description 2.39 kB
/ui/alert-dialog-title 2.34 kB
/ui/audio-track-radio-group 3.00 kB
/ui/buffering-indicator 2.68 kB
/ui/captions-button 2.69 kB
/ui/captions-radio-group 3.05 kB
/ui/cast-button 2.62 kB
/ui/compounds 3.22 kB
/ui/controls 2.93 kB
/ui/error-dialog 2.94 kB
/ui/fullscreen-button 2.60 kB
/ui/hotkey 2.43 kB
/ui/menu 2.97 kB
/ui/mute-button 2.61 kB
/ui/pip-button 2.59 kB
/ui/play-button 2.60 kB
/ui/playback-rate-button 2.66 kB
/ui/playback-rate-radio-group 2.89 kB
/ui/popover 3.14 kB
/ui/poster 2.50 kB
/ui/quality-radio-group 3.03 kB
/ui/seek-button 2.63 kB
/ui/seek-indicator 2.75 kB
/ui/seek-indicator-value 528 B
/ui/slider 2.95 kB
/ui/status-announcer 2.54 kB
/ui/status-indicator 2.68 kB
/ui/status-indicator-value 528 B
/ui/thumbnail 2.64 kB
/ui/time 2.93 kB
/ui/time-slider 2.96 kB
/ui/tooltip 2.94 kB
/ui/volume-indicator 2.59 kB
/ui/volume-indicator-fill 464 B
/ui/volume-indicator-value 463 B
/ui/volume-slider 2.95 kB

Sizes are marginal over the root entry point.

⚛️ @videojs/react — no changes
Presets (7)
Entry Initial Lazy
/video (default) 40.75 kB 54.24 kB
/video (default + hls) 178.69 kB 54.24 kB
/video (minimal) 41.12 kB 54.24 kB
/video (minimal + hls) 179.40 kB 54.24 kB
/audio (default) 33.91 kB 54.24 kB
/audio (minimal) 33.96 kB 54.24 kB
/background 579 B
Media (9)
Entry Initial
/media/background-video 403 B
/media/dash-video 212.98 kB
/media/hlsjs-video 139.79 kB
/media/mux-audio 162.38 kB
/media/mux-video 162.41 kB
/media/native-hls-video 7.26 kB
/media/simple-hls-audio-only 17.33 kB
/media/simple-hls-video 19.39 kB
/media/vimeo-video 10.44 kB
Skins (27)
Entry Type Initial Lazy
/tailwind.css css 161 B
/video/minimal-skin.css css 5.55 kB
/video/skin.css css 5.52 kB
/video/minimal-skin js 40.98 kB 54.24 kB
/video/minimal-skin.tailwind js 47.11 kB 54.24 kB
/video/skin js 40.65 kB 54.24 kB
/video/skin.tailwind js 46.79 kB 54.24 kB
/audio/minimal-skin.css css 3.79 kB
/audio/skin.css css 3.68 kB
/audio/minimal-skin js 33.88 kB 54.24 kB
/audio/minimal-skin.tailwind js 36.11 kB 54.24 kB
/audio/skin js 33.86 kB 54.24 kB
/audio/skin.tailwind js 38.06 kB 54.24 kB
/background/skin.css css 90 B
/background/skin js 272 B
/live-video/minimal-skin.css css 5.55 kB
/live-video/skin.css css 5.52 kB
/live-video/minimal-skin js 35.43 kB 54.24 kB
/live-video/minimal-skin.tailwind js 41.46 kB 54.24 kB
/live-video/skin js 35.44 kB 54.24 kB
/live-video/skin.tailwind js 41.55 kB 54.24 kB
/live-audio/minimal-skin.css css 3.79 kB
/live-audio/skin.css css 3.68 kB
/live-audio/minimal-skin js 24.25 kB 54.24 kB
/live-audio/minimal-skin.tailwind js 27.42 kB 54.24 kB
/live-audio/skin js 24.31 kB 54.24 kB
/live-audio/skin.tailwind js 27.45 kB 54.24 kB
UI Components (33)
Entry Initial
/ui/airplay-button 2.49 kB
/ui/alert-dialog 2.66 kB
/ui/audio-track 2.30 kB
/ui/buffering-indicator 2.51 kB
/ui/captions-button 2.44 kB
/ui/captions-radio-group 2.28 kB
/ui/cast-button 2.44 kB
/ui/controls 2.48 kB
/ui/error-dialog 2.47 kB
/ui/fullscreen-button 2.46 kB
/ui/gesture 2.31 kB
/ui/hotkey 2.27 kB
/ui/live-button 2.37 kB
/ui/menu 2.60 kB
/ui/mute-button 2.42 kB
/ui/pip-button 2.43 kB
/ui/play-button 2.52 kB
/ui/playback-rate 2.46 kB
/ui/playback-rate-button 2.44 kB
/ui/popover 3.06 kB
/ui/poster 2.42 kB
/ui/quality 2.27 kB
/ui/seek-button 2.42 kB
/ui/seek-indicator 2.60 kB
/ui/slider 2.69 kB
/ui/status-announcer 2.34 kB
/ui/status-indicator 2.38 kB
/ui/thumbnail 2.43 kB
/ui/time 2.27 kB
/ui/time-slider 2.58 kB
/ui/tooltip 3.04 kB
/ui/volume-indicator 2.32 kB
/ui/volume-slider 2.57 kB

Sizes are marginal over the root entry point.

🧩 @videojs/core — no changes
Entries (68)
Entry Initial Lazy
. 10.55 kB
/dom 17.70 kB
/dom/media/custom-media-element 2.09 kB
/dom/media/dash 208.97 kB
/dom/media/google-cast 4.03 kB
/dom/media/hls-js 135.87 kB
/dom/media/media-host 1.25 kB
/dom/media/media-played-ranges 576 B
/dom/media/mux 151.37 kB
/dom/media/native-hls 3.07 kB
/dom/media/simple-hls 18.96 kB
/dom/media/simple-hls-audio-only 16.84 kB
/dom/media/vimeo 9.87 kB
/media/predicate 563 B
/i18n 2.66 kB 54.24 kB
/i18n/locales/all 30.58 kB
/i18n/locales/ar 1.21 kB
/i18n/locales/az 1.08 kB
/i18n/locales/bg 1.25 kB
/i18n/locales/bn 1.27 kB
/i18n/locales/bs 1014 B
/i18n/locales/ca 1.05 kB
/i18n/locales/cs 1.04 kB
/i18n/locales/cy 1.00 kB
/i18n/locales/da 996 B
/i18n/locales/de 1.08 kB
/i18n/locales/el 1.45 kB
/i18n/locales/en 621 B
/i18n/locales/es 1010 B
/i18n/locales/et 1.05 kB
/i18n/locales/eu 1.01 kB
/i18n/locales/fa 1.20 kB
/i18n/locales/fi 1.03 kB
/i18n/locales/fr 1.07 kB
/i18n/locales/gd 1.10 kB
/i18n/locales/gl 1014 B
/i18n/locales/he 1.12 kB
/i18n/locales/hi 1.28 kB
/i18n/locales/hr 1.02 kB
/i18n/locales/hu 1.09 kB
/i18n/locales/it 1023 B
/i18n/locales/ja 1.17 kB
/i18n/locales/ko 1.11 kB
/i18n/locales/lv 1.08 kB
/i18n/locales/mr 1.28 kB
/i18n/locales/nb 1000 B
/i18n/locales/ne 1.28 kB
/i18n/locales/nl 1013 B
/i18n/locales/nn 996 B
/i18n/locales/oc 1.05 kB
/i18n/locales/pl 1.12 kB
/i18n/locales/pt 1.01 kB
/i18n/locales/pt-BR 1.01 kB
/i18n/locales/pt-PT 1009 B
/i18n/locales/ro 1.05 kB
/i18n/locales/ru 1.33 kB
/i18n/locales/sk 1.10 kB
/i18n/locales/sl 1.02 kB
/i18n/locales/sr 1.03 kB
/i18n/locales/sv 1022 B
/i18n/locales/te 1.31 kB
/i18n/locales/th 1.29 kB
/i18n/locales/tr 1.07 kB
/i18n/locales/uk 1.36 kB
/i18n/locales/vi 1.08 kB
/i18n/locales/zh 1.01 kB
/i18n/locales/zh-CN 1.01 kB
/i18n/locales/zh-TW 1.01 kB
🏷️ @videojs/element — no changes
Entries (2)
Entry Initial
. 996 B
/context 943 B
📦 @videojs/store — no changes
Entries (3)
Entry Initial
. 1.39 kB
/html 696 B
/react 360 B
🔧 @videojs/utils — no changes
Entries (11)
Entry Initial
/array 104 B
/dom 2.74 kB
/events 319 B
/function 327 B
/object 275 B
/predicate 265 B
/percent 281 B
/string 231 B
/style 190 B
/time 813 B
/number 158 B
📦 @videojs/spf — no changes
Entries (4)
Entry Initial
. 4.45 kB
/dom 6.49 kB
/hls 17.92 kB
/background-video 13.32 kB

ℹ️ How to interpret

JS sizes are initial static graph totals (minified + brotli). Lazy dynamic chunks are shown separately when present.

Icon Meaning
No change
🔺 Increased ≤ 10%
🔴 Increased > 10%
🔽 Decreased
🆕 New (no baseline)

Run pnpm size locally to check current initial sizes.

Base automatically changed from feat/spf-non-zero-pts-relocation to main July 27, 2026 18:55
`getSegmentsToLoad` used a strict `endTime > currentTime` overlap test, so
seeking to `currentTime === duration` selected no segment: the final
segment's end equals `currentTime`. The last segment never loaded,
`endOfStream()` never fired, the MediaSource stayed `open`, and the seek
stalled indefinitely (readyState 1, seeking stuck true).

Include the final segment at the end boundary (`isLast && endTime >=
currentTime`); interior boundaries keep strict `>` so a just-finished
segment isn't reloaded. General segment-loader edge — reproduces on native
0-based sources, independent of non-zero-PTS relocation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cjpillsbury
cjpillsbury force-pushed the fix/spf-exact-end-seek-stall branch from 5d0f367 to f19e8a8 Compare July 27, 2026 19:01
@netlify

netlify Bot commented Jul 27, 2026

Copy link
Copy Markdown

Deploy Preview for vjs10-site ready!

Name Link
🔨 Latest commit 1ef1590
🔍 Latest deploy log https://app.netlify.com/projects/vjs10-site/deploys/6a67cf04690bef0008aa83fc
😎 Deploy Preview https://deploy-preview-1852--vjs10-site.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7a3ea38. Configure here.

Comment thread packages/spf/src/playback/behaviors/dom/load-segments.ts Outdated
Extends the exact-end seek fix. After the first end, `endOfStream()` clamps
`MediaSource.duration` to the true buffered end, which can run slightly past
the model's EXTINF-derived last-segment `endTime`. A later seek to that grown
duration — e.g. seeking to the end again after a `loop` restart — landed just
past the model `endTime`, so `getSegmentsToLoad` dropped the final segment: it
never loaded, the loader went idle, and the seek stalled (`seeking` stuck true,
`MediaSource` `open`).

The terminal segment has no successor and no reachable position past it (the
playhead is clamped to the presentation's range), so it needs no upper-bound
overlap check — it's loadable whenever the forward window reaches it. Interior
segments keep the strict `>` so a just-finished segment isn't reloaded. No
duration value is consulted. In-code live note: for an un-ended presentation
`isLast` is the sliding edge, and loading it eagerly is benign today but the
live effort should confirm the edge / end-of-stream interaction.

Adds unit regression tests (exact-end and post-loop overshoot); reproduced and
verified fixed in Chromium and Firefox (play, seek to duration, loop, seek to
duration again -> loops cleanly instead of stalling).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cjpillsbury
cjpillsbury merged commit 12ae97d into main Jul 28, 2026
26 checks passed
@cjpillsbury
cjpillsbury deleted the fix/spf-exact-end-seek-stall branch July 28, 2026 14:51
@luwes luwes mentioned this pull request Jul 28, 2026
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.

Bug: Seeking to Exact End (currentTime === duration) Stalls — Final Segment Never Loads

2 participants