mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Claude Opus 4.8
parent
4572730e0b
commit
7a6517bfae
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user