From 12ae97ddba743fb6a8b4e09108f0928b2611e820 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 28 Jul 2026 07:51:49 -0700 Subject: [PATCH] fix(spf): load final segment when seeking to exact end (#1828) (#1852) Co-authored-by: Claude Opus 4.8 (1M context) --- .../spf/src/media/buffer/forward-buffer.ts | 21 ++++++-- .../media/buffer/tests/forward-buffer.test.ts | 50 ++++++++++++++++--- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/spf/src/media/buffer/forward-buffer.ts b/packages/spf/src/media/buffer/forward-buffer.ts index d45cf034..d7b24c9f 100644 --- a/packages/spf/src/media/buffer/forward-buffer.ts +++ b/packages/spf/src/media/buffer/forward-buffer.ts @@ -168,10 +168,25 @@ export function getSegmentsToLoad( // Find segments to load: // - Overlaps buffer window [currentTime, targetTime) // - Not already buffered at that time position - const toLoad = segments.filter((seg) => { - // Segment must overlap the buffer window + const toLoad = segments.filter((seg, i) => { const segmentEnd = seg.startTime + seg.duration; - const isInRange = seg.startTime < targetTime && segmentEnd > currentTime; + const isLast = i === segments.length - 1; + // Interior segments use strict `>` so a segment the playhead just finished + // (endTime === currentTime at a boundary) isn't reloaded. 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: it's + // loadable whenever the forward window reaches it. That resolves the + // exact-end seek (#1828) and the post-loop re-seek — where + // `MediaSource.duration`, clamped by `endOfStream()` to the true buffered + // end, has grown a hair past the model's EXTINF-derived end — with no + // dependency on any duration value. + // + // LIVE: for an un-ended live/DVR presentation `isLast` is the sliding edge + // segment; loading it eagerly here is benign today (forward-window-bounded, + // deduped by the buffered check), but the live effort should confirm the + // edge / end-of-stream interaction when it lands. + const overlapsPlayhead = isLast || segmentEnd > currentTime; + const isInRange = seg.startTime < targetTime && overlapsPlayhead; // Must not have a segment buffered at this time position const isNotBuffered = !bufferedStartTimes.has(seg.startTime); diff --git a/packages/spf/src/media/buffer/tests/forward-buffer.test.ts b/packages/spf/src/media/buffer/tests/forward-buffer.test.ts index 74bb97db..522e1139 100644 --- a/packages/spf/src/media/buffer/tests/forward-buffer.test.ts +++ b/packages/spf/src/media/buffer/tests/forward-buffer.test.ts @@ -279,15 +279,14 @@ describe('getSegmentsToLoad', () => { expect(toLoad).toHaveLength(2); // Load both segments within 30s }); - it('should handle currentTime after all segments', () => { + it('past all segments, does not reload interior segments the playhead has passed', () => { + // A playhead past every segment is unreachable in practice (clamped to the + // presentation's range), but the rule stays well-defined: interior segments + // behind the playhead are NOT reloaded. The terminal segment has no + // successor, so it stays selectable — see the exact-end / overshoot cases. const segments: Segment[] = [createSegment(0, 6), createSegment(6, 6)]; - - const bufferedSegments: Segment[] = []; - const currentTime = 100; - - const toLoad = getSegmentsToLoad(segments, bufferedSegments, currentTime); - - expect(toLoad).toHaveLength(0); // No segments in range + const toLoad = getSegmentsToLoad(segments, [], 100); + expect(toLoad.map((s) => s.id)).toEqual(['seg-6']); // only the terminal segment }); it('should not load segments beyond target', () => { @@ -401,4 +400,39 @@ describe('getSegmentsToLoad', () => { expect(toLoad[0]?.id).toBe('seg-18'); }); }); + + describe('exact-end boundary (#1828)', () => { + it('loads the final segment when currentTime is exactly the total duration', () => { + // Regression for #1828: seeking to currentTime === duration must still + // select the last segment. A strict `endTime > currentTime` overlap test + // drops the final segment (whose endTime === duration), so it never loads + // and endOfStream() never fires → the seek stalls forever. + const segments = [createSegment(0, 6), createSegment(6, 6), createSegment(12, 6)] as const; + // Playhead dragged to the exact end (duration = 18), nothing buffered there. + const toLoad = getSegmentsToLoad(segments, [], 18); + expect(toLoad.map((s) => s.id)).toEqual(['seg-12']); + }); + + it('loads the final segment when currentTime slightly exceeds its model end', () => { + // Post-loop re-seek variant of #1828. After the first end, endOfStream() + // clamps MediaSource.duration to the true buffered end, which can run a + // hair past the model's EXTINF-derived last-segment end. A later seek to + // that grown duration lands just past the last segment's endTime — its + // real media still covers the position, so it must still be selected, or + // the loader goes idle and the seek stalls. + const segments = [createSegment(0, 6), createSegment(6, 6), createSegment(12, 6)] as const; + // Model end = 18; duration grew to 18.03, seek lands past seg-12's endTime. + const toLoad = getSegmentsToLoad(segments, [], 18.03); + expect(toLoad.map((s) => s.id)).toEqual(['seg-12']); + }); + + it('does not re-select a finished segment at a mid-stream boundary', () => { + // Guard: the fix is scoped to the final segment. At an interior boundary + // (currentTime === a non-last segment's endTime) the just-finished segment + // must NOT be reloaded — only the segments the playhead is entering. + const segments = [createSegment(0, 6), createSegment(6, 6), createSegment(12, 6)] as const; + const toLoad = getSegmentsToLoad(segments, [], 6); + expect(toLoad.map((s) => s.id)).toEqual(['seg-6', 'seg-12']); + }); + }); });