From 875c8b846bcdf907075d3b12bd63c3665bbf917c Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Mon, 25 May 2026 10:22:40 +0000 Subject: [PATCH] Generated with Hive: Reset media playback position to zero on clip change and metadata load --- src/components/player/media-player.tsx | 17 +- src/lib/__tests__/media-player.test.tsx | 254 ++++++++++++++++++++++++ 2 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 src/lib/__tests__/media-player.test.tsx diff --git a/src/components/player/media-player.tsx b/src/components/player/media-player.tsx index cce188a..aa02a6f 100644 --- a/src/components/player/media-player.tsx +++ b/src/components/player/media-player.tsx @@ -80,6 +80,14 @@ export function MediaPlayer() { const isVideo = typeof mediaUrl === "string" && /\.(mp4|webm|mov)/i.test(mediaUrl) const getMedia = () => (isVideo ? videoRef.current : audioRef.current) + useEffect(() => { + const media = getMedia() + if (!media) return + media.currentTime = 0 + setCurrentTime(0) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playingNode?.ref_id, isVideo]) + useEffect(() => { const media = getMedia() if (!media) return @@ -120,9 +128,14 @@ export function MediaPlayer() { const handleLoadedMetadata = useCallback(() => { const media = getMedia() - if (media) setDuration(media.duration) + if (!media) return + if (media.currentTime > 0) { + media.currentTime = 0 + setCurrentTime(0) + } + setDuration(media.duration) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [setDuration, isVideo]) + }, [setDuration, setCurrentTime, isVideo]) const handleSeek = useCallback( (e: React.MouseEvent) => { diff --git a/src/lib/__tests__/media-player.test.tsx b/src/lib/__tests__/media-player.test.tsx new file mode 100644 index 0000000..39e5922 --- /dev/null +++ b/src/lib/__tests__/media-player.test.tsx @@ -0,0 +1,254 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, act } from "@testing-library/react" +import React from "react" + +// --- mock schema-store --- +vi.mock("@/stores/schema-store", () => ({ + useSchemaStore: vi.fn((selector: (s: { schemas: unknown[] }) => unknown) => + selector({ schemas: [] }) + ), +})) + +// --- player store state --- +let playerState: { + playingNode: Record | null + isPlaying: boolean + currentTime: number + duration: number + volume: number + host: null + isExpanded: boolean + setPlayingNode: ReturnType + setIsPlaying: ReturnType + setCurrentTime: ReturnType + setDuration: ReturnType + setVolume: ReturnType + setHost: ReturnType + setIsExpanded: ReturnType + stop: ReturnType +} + +const mockSetCurrentTime = vi.fn((val: number) => { + playerState.currentTime = val +}) +const mockSetDuration = vi.fn() +const mockSetIsPlaying = vi.fn() +const mockStop = vi.fn() + +function makeNode(id: string, mediaUrl = "https://example.com/video.mp4") { + return { + ref_id: id, + node_type: "Clip", + properties: { media_url: mediaUrl }, + } +} + +function resetPlayerState(node: Record | null = null) { + playerState = { + playingNode: node, + isPlaying: !!node, + currentTime: 0, + duration: 120, + volume: 0.8, + host: null, + isExpanded: false, + setPlayingNode: vi.fn(), + setIsPlaying: mockSetIsPlaying, + setCurrentTime: mockSetCurrentTime, + setDuration: mockSetDuration, + setVolume: vi.fn(), + setHost: vi.fn(), + setIsExpanded: vi.fn(), + stop: mockStop, + } +} + +vi.mock("@/stores/player-store", () => ({ + usePlayerStore: vi.fn((selector?: (s: typeof playerState) => unknown) => { + if (selector) return selector(playerState) + return playerState + }), +})) + +// --- mock ResizeObserver --- +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})) + +// --- mock HTMLMediaElement play/pause (jsdom stubs return undefined) --- +window.HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined) +window.HTMLMediaElement.prototype.pause = vi.fn() + +// --- helpers --- +let videoCurrentTime = 0 +let videoPlayCalled = false +let videoPauseCalled = false + +function mockVideoElement() { + return { + get currentTime() { + return videoCurrentTime + }, + set currentTime(val: number) { + videoCurrentTime = val + }, + play: vi.fn(() => { + videoPlayCalled = true + return Promise.resolve() + }), + pause: vi.fn(() => { + videoPauseCalled = true + }), + volume: 0.8, + duration: 120, + } +} + +// We need to inject the mock into refs after render — we use a module-level ref holder +import { MediaPlayer } from "@/components/player/media-player" + +describe("MediaPlayer", () => { + beforeEach(() => { + videoCurrentTime = 0 + videoPlayCalled = false + videoPauseCalled = false + mockSetCurrentTime.mockClear() + mockSetDuration.mockClear() + mockSetIsPlaying.mockClear() + }) + + it("seeks to 0 on mount when a playingNode is set", async () => { + resetPlayerState(makeNode("clip-1")) + + const { usePlayerStore } = await import("@/stores/player-store") + const mockStore = vi.mocked(usePlayerStore) + mockStore.mockImplementation((selector?: (s: typeof playerState) => unknown) => { + if (selector) return selector(playerState) + return playerState + }) + + const { container } = render() + const video = container.querySelector("video") as HTMLVideoElement & { currentTime: number } + expect(video).not.toBeNull() + + // Simulate the browser leaving the video at a non-zero position + Object.defineProperty(video, "currentTime", { + get: () => videoCurrentTime, + set: (val: number) => { videoCurrentTime = val }, + configurable: true, + }) + video.dispatchEvent(new Event("loadedmetadata")) + + // The effect sets currentTime = 0 + expect(videoCurrentTime).toBe(0) + }) + + it("resets currentTime to 0 when switching to a new playingNode with same media_url", async () => { + resetPlayerState(makeNode("clip-1", "https://example.com/shared.mp4")) + + const { usePlayerStore } = await import("@/stores/player-store") + const mockStore = vi.mocked(usePlayerStore) + mockStore.mockImplementation((selector?: (s: typeof playerState) => unknown) => { + if (selector) return selector(playerState) + return playerState + }) + + const { container, rerender } = render() + const video = container.querySelector("video") as HTMLVideoElement + + Object.defineProperty(video, "currentTime", { + get: () => videoCurrentTime, + set: (val: number) => { videoCurrentTime = val }, + configurable: true, + }) + + // Simulate user watched some of clip-1 + videoCurrentTime = 30 + + // Switch to clip-2 with same media_url + act(() => { + resetPlayerState(makeNode("clip-2", "https://example.com/shared.mp4")) + mockStore.mockImplementation((selector?: (s: typeof playerState) => unknown) => { + if (selector) return selector(playerState) + return playerState + }) + }) + + rerender() + + expect(videoCurrentTime).toBe(0) + expect(mockSetCurrentTime).toHaveBeenCalledWith(0) + }) + + it("corrects non-zero currentTime in handleLoadedMetadata (moov-atom-at-end case)", async () => { + resetPlayerState(makeNode("clip-3")) + + const { usePlayerStore } = await import("@/stores/player-store") + const mockStore = vi.mocked(usePlayerStore) + mockStore.mockImplementation((selector?: (s: typeof playerState) => unknown) => { + if (selector) return selector(playerState) + return playerState + }) + + const { container } = render() + const video = container.querySelector("video") as HTMLVideoElement + + // Simulate browser setting currentTime to 6 before metadata fires + Object.defineProperty(video, "currentTime", { + get: () => videoCurrentTime, + set: (val: number) => { videoCurrentTime = val }, + configurable: true, + }) + videoCurrentTime = 6 + Object.defineProperty(video, "duration", { get: () => 120, configurable: true }) + + act(() => { + video.dispatchEvent(new Event("loadedmetadata")) + }) + + expect(videoCurrentTime).toBe(0) + expect(mockSetCurrentTime).toHaveBeenCalledWith(0) + }) + + it("handleSeek sets media.currentTime to the correct ratio-derived value (regression guard)", async () => { + resetPlayerState(makeNode("clip-4")) + playerState.duration = 100 + + const { usePlayerStore } = await import("@/stores/player-store") + const mockStore = vi.mocked(usePlayerStore) + mockStore.mockImplementation((selector?: (s: typeof playerState) => unknown) => { + if (selector) return selector(playerState) + return playerState + }) + + const { container } = render() + const video = container.querySelector("video") as HTMLVideoElement + + Object.defineProperty(video, "currentTime", { + get: () => videoCurrentTime, + set: (val: number) => { videoCurrentTime = val }, + configurable: true, + }) + + // Find the progress bar div (has h-1 class) + const progressBar = container.querySelector(".h-1.w-full.cursor-pointer") as HTMLDivElement + expect(progressBar).not.toBeNull() + + // Mock getBoundingClientRect so ratio = 0.5 → currentTime = 50 + vi.spyOn(progressBar, "getBoundingClientRect").mockReturnValue({ + left: 0, right: 200, width: 200, top: 0, bottom: 10, height: 10, x: 0, y: 0, + toJSON: () => ({}), + }) + + act(() => { + progressBar.dispatchEvent( + new MouseEvent("click", { bubbles: true, clientX: 100 }) + ) + }) + + expect(videoCurrentTime).toBe(50) + expect(mockSetCurrentTime).toHaveBeenCalledWith(50) + }) +})