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 7b5004c8..f5bdbc32 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 @@ -122,6 +122,12 @@ function seekToLiveEdgeSetup({ ) ); + // The source (manifest url) the initial edge seek has already fired for. The + // seek is once *per source* — `entry` fires on every entry into `live`, but a + // transient precondition flip (e.g. the live window briefly unknown mid + // track-switch) re-enters `live` for the *same* source and must not re-seek. + let seekedForSource: string | undefined; + return createMachineReactor({ initial: 'inactive', monitor: () => derivedStateSignal.get(), @@ -129,11 +135,19 @@ function seekToLiveEdgeSetup({ inactive: {}, live: { - // One-time seek into the window so the loader dispatches an in-window - // range and preload shows the right frame. Fires once per entry into - // `live`; a source change exits to `inactive`, so a new source re-seeks. - // Live-specific — a future DVR mode skips this (starts in place). + // One-time-per-source seek into the window so the loader dispatches an + // in-window range and preload shows the right frame. The business rule is + // "on initial load of a source, jump near the live edge — once" — not + // "whenever the live preconditions hold." Latching on the source url makes + // that explicit: a genuine source change (new url) re-seeks; a transient + // re-entry into `live` does not (which would otherwise yank a viewer who + // has scrubbed back into the DVR window). The window-exit guard below is + // the separate, continuous rule. Live-specific — a future DVR mode skips + // even the first seek (starts in place). entry: () => { + const source = peek(state.presentation)?.url; + if (source !== undefined && source === seekedForSource) return; + seekedForSource = source; // The monitor guarantees a media element + live edge while in `live`. const mediaElement = context.mediaElement.get()!; const { liveEdgeStart } = getLiveEdge({ state, config })!; 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 afb8b289..15045d60 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 @@ -183,6 +183,63 @@ describe('seekToLiveEdge', () => { cleanup(); }); + it('seeks once per source — a transient re-entry into live does not re-fire the edge seek', async () => { + const ms = fakeMediaSource(); + const el = fakeMediaElement(); + + const { cleanup, state } = run({ + presentation: makePresentation(), + trackId: 'v-1', + mediaElement: el, + mediaSource: ms, + }); + expect(el.currentTime).toBe(104); // initial edge seek + + // Viewer scrubs back into the DVR window (still inside it, above the start). + el.currentTime = 100; + + // A transient precondition flip (here the anchor blinks, standing in for the + // live edge briefly going unknown mid track-switch) takes the reactor + // inactive → live for the SAME source. + state.presentationAnchor.set(undefined); + await flush(); + state.presentationAnchor.set(1000); + await flush(); + + // Not yanked back to the edge — the seek is once per source. + expect(el.currentTime).toBe(100); + + cleanup(); + }); + + it('re-seeks on a genuine source change (new url)', async () => { + const ms = fakeMediaSource(); + const el = fakeMediaElement(); + + const { cleanup, state } = run({ + presentation: makePresentation(), + trackId: 'v-1', + mediaElement: el, + mediaSource: ms, + }); + expect(el.currentTime).toBe(104); + el.currentTime = 100; // viewer moved + + // Source change goes through an unresolved presentation (exits live), then a + // new resolved source with a different url. + state.presentation.set(undefined); + await flush(); + const next = makePresentation(200, 80); + next.url = 'https://example.com/other.m3u8'; + state.presentation.set(next); + await flush(); + + // New source → re-seeks to its live edge (window [200,210], 6s latency → 204). + expect(el.currentTime).toBe(204); + + cleanup(); + }); + describe('live-window playhead guard', () => { function started() { const ms = fakeMediaSource();