diff --git a/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts b/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts index f5bdbc32..477c255f 100644 --- a/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts +++ b/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts @@ -17,8 +17,10 @@ * `entry`, it fires once per entry into `live`; a source change exits to * `inactive`, so the next source re-seeks (no closure latch to reset). * - * Window-exit guard: while playing (not paused, not mid-seek), reposition to the - * live edge when the playhead has fallen behind the window start. Two triggers: + * Window-exit guard: while playing (not paused), reposition to the live edge + * when the playhead has fallen behind the window start — including when a seek + * to a now-evicted position has stranded the playhead (such a seek can never + * settle, so we rescue rather than wait on it). Two triggers: * the **window-update re-fire** (the guard reads the live edge, so each reload / * slide re-runs it — this catches a stall, where `timeupdate` stops but the * playlist keeps reloading) and a **`play` listener** for immediate reactivity on @@ -171,8 +173,16 @@ function seekToLiveEdgeSetup({ const { start: windowStart, liveEdgeStart } = getLiveEdge({ state, config })!; const mediaElement = peek(context.mediaElement)!; const reposition = () => { - // Don't yank a paused viewer or fight an in-flight seek (DVR scrub-back). - if (mediaElement.paused || mediaElement.seeking) return; + // Don't yank a paused viewer. We deliberately DON'T bail on + // `mediaElement.seeking`: a seek whose target sits behind the window + // start can never complete (the data has slid out of the window and + // been evicted), so `seeking` would stay true forever and strand the + // playhead — the very stall this guard exists to rescue. The + // `currentTime < windowStart` test is itself the precise + // discriminator: an in-window DVR scrub-back lands at + // `currentTime >= windowStart` and never trips it, so only a stuck + // out-of-window seek is repositioned. + if (mediaElement.paused) return; if (mediaElement.currentTime < windowStart - REPOSITION_TOLERANCE) { mediaElement.currentTime = liveEdgeStart; } diff --git a/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts index 15045d60..53b00d05 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts +++ b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts @@ -300,17 +300,35 @@ describe('seekToLiveEdge', () => { cleanup(); }); - it('does not reposition while a seek is in progress; repositions once it settles', () => { + it('does not yank an in-flight seek to an in-window position (DVR scrub-back)', () => { const { el, cleanup } = started(); el.paused = false; - el.currentTime = 90; + el.currentTime = 102; // scrubbing back to a valid position within [100, 110] el.seeking = true; el.dispatchEvent(new Event('play')); - expect(el.currentTime).toBe(90); // seek in flight → untouched + // In-window → never trips the `< windowStart` guard, so the seek proceeds + // untouched. The guard discriminates on position, not the `seeking` flag. + expect(el.currentTime).toBe(102); + cleanup(); + }); - el.seeking = false; - el.dispatchEvent(new Event('play')); - expect(el.currentTime).toBe(104); + it('rescues a seek stranded behind the window start, even while still seeking', async () => { + const { el, state, cleanup } = started(); + el.paused = false; + // A scrub toward the back of the window lands on data that has since slid + // out and been evicted: the seek hangs (readyState drops, `seeking` stays + // true) and `currentTime` is frozen behind the window start. + el.seeking = true; + el.readyState = 1; // HAVE_METADATA — stalled, no data at the target + el.currentTime = 90; + + // The window-update re-fire (a playlist reload — `timeupdate` is silent + // during the stall) catches it: 90 < new windowStart 200. Previously the + // `seeking` bail blocked this and the playhead stranded permanently. + state.presentation.set(makePresentation(200, 100)); + await flush(); + + expect(el.currentTime).toBe(204); // snapped to the new live edge despite `seeking` cleanup(); });