From c605df50f61e7e50067133e4e79458607fb4d3a9 Mon Sep 17 00:00:00 2001 From: rahim Date: Mon, 9 Mar 2026 19:45:00 -0700 Subject: [PATCH] fix(core): optimistic current time update on seek to prevent slider snap-back (#799) --- .../src/dom/store/features/tests/time.test.ts | 45 +++++++++++++++++++ packages/core/src/dom/store/features/time.ts | 8 +++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/core/src/dom/store/features/tests/time.test.ts b/packages/core/src/dom/store/features/tests/time.test.ts index b0eaab45..cf2ecc6a 100644 --- a/packages/core/src/dom/store/features/tests/time.test.ts +++ b/packages/core/src/dom/store/features/tests/time.test.ts @@ -151,6 +151,51 @@ describe('timeFeature', () => { const result2 = await seek2Promise; expect(result2).toBe(20); }); + + it('optimistically updates currentTime before seeked event fires', () => { + const video = createMockVideo({ readyState: HTMLMediaElement.HAVE_METADATA }); + const store = createStore()(timeFeature); + store.attach({ media: video, container: null }); + + expect(store.state.currentTime).toBe(0); + + // Start seek but don't fire any DOM events. + store.seek(45); + + // Store should reflect target time immediately (no waiting for seeked). + expect(store.state.currentTime).toBe(45); + }); + + it('optimistically sets seeking to true before seeking event fires', () => { + const video = createMockVideo({ readyState: HTMLMediaElement.HAVE_METADATA }); + const store = createStore()(timeFeature); + store.attach({ media: video, container: null }); + + expect(store.state.seeking).toBe(false); + + store.seek(45); + + expect(store.state.seeking).toBe(true); + }); + + it('optimistic seeking is corrected by seeked event', async () => { + const video = createMockVideo({ readyState: HTMLMediaElement.HAVE_METADATA }); + const store = createStore()(timeFeature); + store.attach({ media: video, container: null }); + + const resultPromise = store.seek(45); + + expect(store.state.seeking).toBe(true); + + // Simulate browser completing seek. + Object.defineProperty(video, 'seeking', { value: false, configurable: true }); + video.dispatchEvent(new Event('seeked')); + + await resultPromise; + + expect(store.state.seeking).toBe(false); + expect(store.state.currentTime).toBe(45); + }); }); }); }); diff --git a/packages/core/src/dom/store/features/time.ts b/packages/core/src/dom/store/features/time.ts index 495812a9..679e762a 100644 --- a/packages/core/src/dom/store/features/time.ts +++ b/packages/core/src/dom/store/features/time.ts @@ -7,7 +7,7 @@ import { signalKeys } from '../signal-keys'; export const timeFeature = definePlayerFeature({ name: 'time', - state: ({ target, signals }): MediaTimeState => ({ + state: ({ target, signals, set }): MediaTimeState => ({ currentTime: 0, duration: 0, seeking: false, @@ -23,6 +23,12 @@ export const timeFeature = definePlayerFeature({ // Perform the seek and wait for it to complete. const clampedTime = Math.max(0, Math.min(time, media.duration || Infinity)); + + // Optimistic update: reflect the target position immediately so UI consumers + // (e.g. time slider) don't snap back to the old currentTime while waiting + // for the browser's async seeking/seeked events. + set({ currentTime: clampedTime, seeking: true }); + media.currentTime = clampedTime; await onEvent(media, 'seeked', { signal }).catch(noop);