diff --git a/packages/spf/src/playback/behaviors/dom/sync-live-seekable-range.ts b/packages/spf/src/playback/behaviors/dom/sync-live-seekable-range.ts index 01223cca..eb7d704e 100644 --- a/packages/spf/src/playback/behaviors/dom/sync-live-seekable-range.ts +++ b/packages/spf/src/playback/behaviors/dom/sync-live-seekable-range.ts @@ -57,13 +57,15 @@ function syncLiveSeekableRangeSetup({ return effect(() => { const mediaSource = context.mediaSource.get(); const liveWindow = liveWindowFromState(state); - if (!mediaSource || mediaSource.readyState !== 'open' || !liveWindow) return; + if (!mediaSource || !liveWindow) return; // Re-declared as the window slides so seekable tracks the live window // (the full DVR range remains seekable; seek-to-live-edge starts near the edge). - // No try/catch: `setLiveSeekableRange` throws only on a non-'open' readyState - // (checked synchronously just above — no await between, so it can't change) or - // an invalid range (`liveWindowFor` guarantees 0 ≤ start ≤ end). + // No readyState check, no try/catch: `setLiveSeekableRange` throws only on a + // non-'open' readyState or an invalid range, and neither can occur here — + // `setupMediaSource` publishes `context.mediaSource` only while open, and a + // non-null live window means the timeline-bearing track is still `Infinity` + // (so `endOfStream` hasn't ended the MS); `liveWindowFor` guarantees 0 ≤ start ≤ end. mediaSource.setLiveSeekableRange(liveWindow.start, liveWindow.end); }); } diff --git a/packages/spf/src/playback/behaviors/dom/tests/sync-live-seekable-range.test.ts b/packages/spf/src/playback/behaviors/dom/tests/sync-live-seekable-range.test.ts index 869398b0..915fc251 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/sync-live-seekable-range.test.ts +++ b/packages/spf/src/playback/behaviors/dom/tests/sync-live-seekable-range.test.ts @@ -65,11 +65,23 @@ describe('syncLiveSeekableRange', () => { cleanup(); }); - it('does nothing until the MediaSource is open', () => { - const ms = fakeMediaSource('closed'); - const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaSource: ms }); + it('declares only once the MediaSource is published (open)', async () => { + // `setupMediaSource` publishes `context.mediaSource` only once open, so an + // unpublished (absent) MediaSource is the "not ready" gate — no `readyState` + // check needed (presence + a live window ⟹ open). + const ms = fakeMediaSource(); + const state = { + presentation: signal(makePresentation()), + selectedVideoTrackId: signal('v-1'), + }; + const context = { mediaSource: signal(undefined) }; + const cleanup = syncLiveSeekableRange.setup({ state, context, config: {} }) as () => void; - expect(ms.setLiveSeekableRange).not.toHaveBeenCalled(); + expect(ms.setLiveSeekableRange).not.toHaveBeenCalled(); // unpublished → no declaration + + context.mediaSource.set(ms); // published (open) + await Promise.resolve(); // effect re-runs on a microtask + expect(ms.setLiveSeekableRange).toHaveBeenCalledWith(100, 110); cleanup(); });