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..9774ee83772 --- /dev/null +++ b/apps/web/__tests__/unit/playback-store.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPlaybackStore } from "@/app/s/[videoId]/_components/playback/playback-store"; + +function createMockVideoElement(initialProps?: { + currentTime?: number; + duration?: number; + paused?: boolean; +}) { + const listeners = new Map void>>(); + const element = { + currentTime: initialProps?.currentTime ?? 0, + duration: initialProps?.duration ?? Number.NaN, + paused: initialProps?.paused ?? true, + ended: false, + addEventListener: vi.fn((event: string, handler: () => void) => { + if (!listeners.has(event)) { + listeners.set(event, new Set()); + } + listeners.get(event)?.add(handler); + }), + removeEventListener: vi.fn((event: string, handler: () => void) => { + listeners.get(event)?.delete(handler); + }), + play: vi.fn(), + pause: vi.fn(), + } as unknown as HTMLVideoElement; + + const dispatchEvent = (event: string) => { + listeners.get(event)?.forEach((handler) => { + handler(); + }); + }; + + return { element, dispatchEvent, listeners }; +} + +describe("createPlaybackStore", () => { + it("notifies subscribers when duration loads while paused at time zero (#2931)", () => { + const store = createPlaybackStore(0); + const { element, dispatchEvent } = createMockVideoElement({ + currentTime: 0, + duration: Number.NaN, + paused: true, + }); + + store.attach(element); + expect(store.getDuration()).toBe(0); + + const subscriber = vi.fn(); + store.subscribe(subscriber); + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriber).toHaveBeenLastCalledWith(0); + + (element as { duration: number }).duration = 2.0; + dispatchEvent("loadedmetadata"); + + expect(subscriber).toHaveBeenCalledTimes(2); + expect(store.getDuration()).toBe(2.0); + }); + + it("notifies subscribers when durationchange fires while paused", () => { + const store = createPlaybackStore(0); + const { element, dispatchEvent } = createMockVideoElement({ + currentTime: 0, + duration: Number.NaN, + paused: true, + }); + + store.attach(element); + + const subscriber = vi.fn(); + store.subscribe(subscriber); + expect(subscriber).toHaveBeenCalledTimes(1); + + (element as { duration: number }).duration = 4.5; + dispatchEvent("durationchange"); + + expect(subscriber).toHaveBeenCalledTimes(2); + expect(store.getDuration()).toBe(4.5); + }); + + it("suppresses redundant notifications when neither time nor duration changed", () => { + const store = createPlaybackStore(0); + const { element, dispatchEvent } = createMockVideoElement({ + currentTime: 0, + duration: 3.0, + paused: true, + }); + + store.attach(element); + + const subscriber = vi.fn(); + store.subscribe(subscriber); + expect(subscriber).toHaveBeenCalledTimes(1); + + dispatchEvent("timeupdate"); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it("notifies subscribers when setFallbackDuration changes the effective duration", () => { + const store = createPlaybackStore(0); + const subscriber = vi.fn(); + store.subscribe(subscriber); + expect(subscriber).toHaveBeenCalledTimes(1); + expect(store.getDuration()).toBe(0); + + store.setFallbackDuration(10.0); + expect(subscriber).toHaveBeenCalledTimes(2); + expect(store.getDuration()).toBe(10.0); + + store.setFallbackDuration(10.0); + expect(subscriber).toHaveBeenCalledTimes(2); + }); +}); 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..74b2c26bdc8 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,15 @@ export function createPlaybackStore(fallbackDuration: number | null = 0) { /** Pull `currentTime`/`duration` off the element and notify time subscribers. */ const sample = (force = false) => { + let durationChanged = false; if (element && isFiniteDuration(element.duration)) { - duration = element.duration; + if (duration !== element.duration) { + duration = element.duration; + durationChanged = true; + } } const next = element?.currentTime ?? time; - if (next === time && !force) return; + if (next === time && !force && !durationChanged) return; time = next; emitTime(); }; @@ -174,8 +178,12 @@ export function createPlaybackStore(fallbackDuration: number | null = 0) { const getPlaying = () => playing; const setFallbackDuration = (next: number | null) => { - fallback = normalizeDuration(next); + const nextFallback = normalizeDuration(next); + const durationChanged = + !isFiniteDuration(duration) && fallback !== nextFallback; + fallback = nextFallback; if (!isFiniteDuration(duration)) duration = fallback; + if (durationChanged) emitTime(); }; const seek = (target: number) => {