diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57810c00587..9d712a75a5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,7 @@ jobs: __tests__/unit/linkified-text.test.ts __tests__/unit/loom-import-ui.test.ts __tests__/unit/media-player-store-refs.test.ts + __tests__/unit/playback-store.test.ts __tests__/unit/seo-demo-videos.test.ts __tests__/unit/signed-baa-card.test.ts __tests__/unit/summary-editor.test.ts diff --git a/apps/web/__tests__/unit/playback-store.test.ts b/apps/web/__tests__/unit/playback-store.test.ts new file mode 100644 index 00000000000..5022e68b5a0 --- /dev/null +++ b/apps/web/__tests__/unit/playback-store.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPlaybackStore } from "@/app/s/[videoId]/_components/playback/playback-store"; + +describe("playback store", () => { + it.each(["loadedmetadata", "durationchange"])( + "publishes the loaded duration on %s while paused at zero", + (event) => { + const video = Object.assign(new EventTarget(), { + currentTime: 0, + duration: Number.NaN, + paused: true, + ended: false, + }); + const store = createPlaybackStore(); + let observedDuration = 0; + store.subscribe(() => { + observedDuration = store.getDuration(); + }); + store.attach(video as unknown as HTMLVideoElement); + + video.duration = 120; + video.dispatchEvent(new Event(event)); + + expect(observedDuration).toBe(120); + expect(store.getCurrentTime()).toBe(0); + store.detach(); + }, + ); + + it("publishes duration changes without repeating unchanged samples", () => { + const video = Object.assign(new EventTarget(), { + currentTime: 5, + duration: 30, + paused: true, + ended: false, + }); + const store = createPlaybackStore(30); + store.attach(video as unknown as HTMLVideoElement); + const subscriber = vi.fn(() => store.getDuration()); + store.subscribe(subscriber); + subscriber.mockClear(); + + video.duration = 45; + video.dispatchEvent(new Event("durationchange")); + video.dispatchEvent(new Event("loadedmetadata")); + video.dispatchEvent(new Event("timeupdate")); + + expect(subscriber).toHaveBeenCalledExactlyOnceWith(5); + expect(subscriber).toHaveReturnedWith(45); + store.detach(); + }); + + it("keeps time updates and the last finite duration until detached", () => { + const video = Object.assign(new EventTarget(), { + currentTime: 0, + duration: 30, + paused: true, + ended: false, + }); + const store = createPlaybackStore(); + store.attach(video as unknown as HTMLVideoElement); + const subscriber = vi.fn(() => store.getDuration()); + store.subscribe(subscriber); + subscriber.mockClear(); + + video.duration = Number.POSITIVE_INFINITY; + video.dispatchEvent(new Event("durationchange")); + expect(subscriber).not.toHaveBeenCalled(); + expect(store.getDuration()).toBe(30); + + video.currentTime = 6; + video.dispatchEvent(new Event("timeupdate")); + expect(subscriber).toHaveBeenCalledExactlyOnceWith(6); + expect(subscriber).toHaveReturnedWith(30); + + store.detach(); + video.currentTime = 7; + video.duration = 45; + video.dispatchEvent(new Event("durationchange")); + video.dispatchEvent(new Event("timeupdate")); + expect(subscriber).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/app/s/[videoId]/_components/playback/playback-store.ts b/apps/web/app/s/[videoId]/_components/playback/playback-store.ts index c6e38546207..0bc286e1382 100644 --- a/apps/web/app/s/[videoId]/_components/playback/playback-store.ts +++ b/apps/web/app/s/[videoId]/_components/playback/playback-store.ts @@ -46,11 +46,12 @@ export function createPlaybackStore(fallbackDuration: number | null = 0) { /** Pull `currentTime`/`duration` off the element and notify time subscribers. */ const sample = (force = false) => { + const previousDuration = duration; if (element && isFiniteDuration(element.duration)) { duration = element.duration; } const next = element?.currentTime ?? time; - if (next === time && !force) return; + if (next === time && duration === previousDuration && !force) return; time = next; emitTime(); };