fix(spf): seek to the live edge once per source

The initial "jump near the live edge" was modeled as a reactor entry, so it
re-fired on every entry into `live`. A transient precondition flip (e.g. the live
window briefly unknown mid track-switch) re-enters `live` for the same source and
yanked a viewer who had scrubbed back into the DVR window. The actual rule is
"on initial load of a source, jump near the edge — once," so latch it on the
source url: a genuine source change re-seeks; a transient re-entry does not. The
continuous window-exit guard is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 10:00:47 -07:00
co-authored by Claude Opus 4.8
parent 4572730e0b
commit 7a6517bfae
2 changed files with 75 additions and 4 deletions
@@ -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<SeekToLiveEdgeFsmState>({
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 })!;
@@ -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();