diff --git a/packages/spf/src/playback/behaviors/anchor-live-tracks.ts b/packages/spf/src/playback/behaviors/anchor-live-tracks.ts index 0bc437c8..c467de9f 100644 --- a/packages/spf/src/playback/behaviors/anchor-live-tracks.ts +++ b/packages/spf/src/playback/behaviors/anchor-live-tracks.ts @@ -125,24 +125,32 @@ function anchorLiveTracksSetup({ return createMachineReactor({ initial: 'unanchored', - // Re-checks buffer availability on each reload (the resolver is read untracked - // by the engine, so reloads — not buffer ticks — drive the transition). An - // unresolved presentation drops back to idle. + // Checks buffer availability on each reload until anchored (the resolver is + // read untracked by the engine, so reloads — not buffer ticks — drive the + // transition). An unresolved presentation drops back to idle. monitor: () => { const presentation = state.presentation.get(); if (!isResolvedPresentation(presentation)) return 'unanchored'; + // Sticky per source: once the anchor is published, stay `anchored` for the + // lifetime of this resolved presentation. Only a source change — which + // resets the presentation to an unresolved value (handled above) — reverts. + // A *transient* loss of buffer ground truth (underrun, flush, seek) must NOT + // drop the established anchor: doing so re-opens the seekToLiveEdge gate and + // re-fires its one-time live-edge seek, jumping the playhead. "Pin-once" + // means pin once per source — see live-presentation-anchor.md. + if (state.liveAnchor.get() !== undefined) return 'anchored'; return isUndefined(deriveBufferAnchor(presentation)) ? 'unanchored' : 'anchored'; }, states: { // Reset the published anchor per source so a new source re-gates the seek. unanchored: { entry: () => state.liveAnchor.set(undefined) }, anchored: { - // Establish the shared anchor once and stamp it onto every track. Runs - // once per entry; a source change exits to `unanchored`, so the next - // source re-establishes. Re-deriving the same buffer anchor is idempotent - // (segment PDT and native-PTS start are stable), and - // `positionAllTracksToAnchor` writes no new reference when nothing moved — - // so a transient re-entry is a no-op. + // Establish the shared anchor once and stamp it onto every track. The + // sticky monitor keeps us `anchored` for the source once `liveAnchor` is + // published, so this runs exactly once per source; only a source change + // (exit to `unanchored`) re-arms it. (Were it to re-enter, re-deriving the + // same buffer anchor is idempotent and `positionAllTracksToAnchor` writes + // no new reference when nothing moved — so it would still be a no-op.) entry: () => { const presentation = state.presentation.get(); if (!isResolvedPresentation(presentation)) return; diff --git a/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts b/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts index 352c096f..2f9e546e 100644 --- a/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts +++ b/packages/spf/src/playback/behaviors/tests/anchor-live-tracks.test.ts @@ -148,6 +148,62 @@ describe('anchorLiveTracks', () => { cleanup(); }); + it('keeps the established anchor through a transient loss of buffer ground truth (sticky per source)', async () => { + let hasBuffer = true; + const { cleanup, state } = run({ + presentation: makePresentation([makeVideoTrack()]), + config: { + resolveBufferedAnchor: () => + hasBuffer ? { trackId: 'v-1', segmentId: 'segment-85', actualStart: 500 } : undefined, + }, + }); + + expect(state.liveAnchor.get()).toBe(500); + + // Buffer ground truth momentarily vanishes (underrun / flush / seek), then a + // reload fires. The established anchor must persist — dropping it re-opens the + // seekToLiveEdge gate and re-fires its one-time live-edge seek. Only a source + // change (unresolved presentation) reverts. + hasBuffer = false; + const carried = makeVideoTrack(); + carried.startTime = 500; + carried.startDate = 500; + carried.segments = [{ ...carried.segments[0]!, startTime: 500, startDate: 1000 }]; + state.presentation.set(makePresentation([carried])); + await flush(); + await flush(); + + expect(state.liveAnchor.get()).toBe(500); + + cleanup(); + }); + + it('reverts the anchor on a source change, then re-establishes for the new source', async () => { + let actualStart = 500; + const { cleanup, state } = run({ + presentation: makePresentation([makeVideoTrack()]), + config: { resolveBufferedAnchor: () => ({ trackId: 'v-1', segmentId: 'segment-85', actualStart }) }, + }); + + expect(state.liveAnchor.get()).toBe(500); + + // Source change → presentation reset to an unresolved value: the anchor clears + // so the new source re-gates the seek. + state.presentation.set({ url: 'https://example.com/new.m3u8' }); + await flush(); + await flush(); + expect(state.liveAnchor.get()).toBeUndefined(); + + // New source resolves with its own buffer truth → re-establishes. + actualStart = 700; + state.presentation.set(makePresentation([makeVideoTrack()])); + await flush(); + await flush(); + expect(state.liveAnchor.get()).toBe(300); // video seg PDT 1000 − actualStart 700 + + cleanup(); + }); + it('establishes once — a later reload is left to the parser (no re-establish even if buffer drifts)', async () => { let actualStart = 500; const { cleanup, state } = run({