diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx index 682b0b9..e91de80 100644 --- a/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { useCursorIdle } from '@/components/video-page/hooks/use-cursor-idle'; import Hls from 'hls.js'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; @@ -128,8 +129,7 @@ export default function CompareVersionsPageClient({ const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [isDragging, setIsDragging] = useState(false); - const [cursorIdle, setCursorIdle] = useState(false); - const cursorIdleTimerRef = useRef | null>(null); + const { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave } = useCursorIdle(isPlaying); const timelineRef = useRef(null); // Map of versionId -> YT.Player or Custom Adapter @@ -409,57 +409,6 @@ export default function CompareVersionsPageClient({ handleSeek(currentTimeRef.current); }, [isDragging, handleSeek]); - const handleVideoMouseMove = useCallback(() => { - setCursorIdle(false); - if (cursorIdleTimerRef.current) { - clearTimeout(cursorIdleTimerRef.current); - } - - if (isPlaying) { - cursorIdleTimerRef.current = setTimeout(() => { - setCursorIdle(true); - }, 1000); - } - }, [isPlaying]); - - const handleVideoMouseLeave = useCallback(() => { - if (cursorIdleTimerRef.current) { - clearTimeout(cursorIdleTimerRef.current); - } - setCursorIdle(false); - }, []); - - useEffect(() => { - return () => { - if (cursorIdleTimerRef.current) { - clearTimeout(cursorIdleTimerRef.current); - } - }; - }, []); - - useEffect(() => { - if (cursorIdleTimerRef.current) { - clearTimeout(cursorIdleTimerRef.current); - cursorIdleTimerRef.current = null; - } - - if (!isPlaying) { - setCursorIdle(false); - return; - } - - cursorIdleTimerRef.current = setTimeout(() => { - setCursorIdle(true); - }, 1000); - - return () => { - if (cursorIdleTimerRef.current) { - clearTimeout(cursorIdleTimerRef.current); - cursorIdleTimerRef.current = null; - } - }; - }, [isPlaying]); - // Keyboard shortcuts (matching video page) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/components/video-page/hooks/use-cursor-idle.ts b/components/video-page/hooks/use-cursor-idle.ts new file mode 100644 index 0000000..710a17d --- /dev/null +++ b/components/video-page/hooks/use-cursor-idle.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** How long the cursor has to sit still over the player before it and the overlay hide. */ +export const CURSOR_IDLE_DELAY_MS = 1000; + +/** + * Tracks whether the cursor has rested over the player long enough to hide it + * and the play/pause overlay. Playback can start without the cursor moving (a + * click, a key, the resume after a scrub), so the countdown is re-armed on + * every playback change. Only pointer activity wakes the cursor: a pause/play + * pair the element emits on its own (rebuffering, a source switch) leaves the + * idle state alone, so the chrome does not flash back for a second. + */ +export function useCursorIdle(isPlaying: boolean) { + const [cursorIdle, setCursorIdle] = useState(false); + const timerRef = useRef | null>(null); + const isCursorOverPlayerRef = useRef(false); + + // Restart the countdown. It only runs while the cursor is over the player + // and playback is running; otherwise nothing is pending. + const armTimer = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + + if (!isCursorOverPlayerRef.current || !isPlaying) return; + + timerRef.current = setTimeout(() => { + setCursorIdle(true); + }, CURSOR_IDLE_DELAY_MS); + }, [isPlaying]); + + const handleVideoMouseMove = useCallback(() => { + isCursorOverPlayerRef.current = true; + setCursorIdle(false); + armTimer(); + }, [armTimer]); + + const handleVideoMouseLeave = useCallback(() => { + isCursorOverPlayerRef.current = false; + setCursorIdle(false); + armTimer(); + }, [armTimer]); + + useEffect(() => { + armTimer(); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [armTimer]); + + return { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave }; +} diff --git a/components/video-page/hooks/use-video-player.ts b/components/video-page/hooks/use-video-player.ts index 9af3571..ac18b3e 100644 --- a/components/video-page/hooks/use-video-player.ts +++ b/components/video-page/hooks/use-video-player.ts @@ -33,9 +33,7 @@ import { resolveSkipAmount as resolveSkipAmountFor, timeFromClientX as timeFromClientXWithin, } from '@/components/video-page/hooks/video-player-utils'; - -/** How long the cursor has to sit still over the player before it and the overlay hide. */ -const CURSOR_IDLE_DELAY_MS = 1000; +import { useCursorIdle } from '@/components/video-page/hooks/use-cursor-idle'; interface UseVideoPlayerParams { activeVersion: Version | undefined; @@ -117,9 +115,7 @@ export function useVideoPlayer({ const previousVersionKeyRef = useRef(null); const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false); const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState(0); - const [cursorIdle, setCursorIdle] = useState(false); - const cursorIdleTimerRef = useRef | null>(null); - const isCursorOverPlayerRef = useRef(false); + const { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave } = useCursorIdle(isPlaying); const bunnyRetryTimerRef = useRef | null>(null); const bunnyFrameCallbackIdRef = useRef(null); const bunnyFrameSampleRef = useRef<{ mediaTime: number; presentedFrames: number } | null>(null); @@ -216,42 +212,6 @@ export function useVideoPlayer({ return () => observer.disconnect(); }, [activeVersionId, bunnyViewportRef]); - // Restart the idle countdown from "cursor active". The cursor only counts as - // idle while it is over the player and there is something to hide (playback - // running, or fullscreen chrome); otherwise it stays visible. - const armCursorIdleTimer = useCallback(() => { - if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); - cursorIdleTimerRef.current = null; - setCursorIdle(false); - - if (!isCursorOverPlayerRef.current) return; - if (!isPlaying && !isFullscreenMode) return; - - cursorIdleTimerRef.current = setTimeout(() => { - setCursorIdle(true); - }, CURSOR_IDLE_DELAY_MS); - }, [isFullscreenMode, isPlaying]); - - const handleVideoMouseMove = useCallback(() => { - isCursorOverPlayerRef.current = true; - armCursorIdleTimer(); - }, [armCursorIdleTimer]); - - const handleVideoMouseLeave = useCallback(() => { - isCursorOverPlayerRef.current = false; - armCursorIdleTimer(); - }, [armCursorIdleTimer]); - - // Playback can change without the cursor moving: a click or a key starts it, - // and a scrub pauses then resumes it. Each of those needs a fresh countdown, - // or a still cursor over the player never goes idle and the overlay stays. - useEffect(() => { - armCursorIdleTimer(); - return () => { - if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); - }; - }, [armCursorIdleTimer]); - useEffect(() => { if (isApiLoaded) return; diff --git a/tests/component/hooks/use-video-player.test.ts b/tests/component/hooks/use-video-player.test.ts index 9847e1e..0212afe 100644 --- a/tests/component/hooks/use-video-player.test.ts +++ b/tests/component/hooks/use-video-player.test.ts @@ -433,20 +433,25 @@ describe('useVideoPlayer cursor idling', () => { expect(result.current.cursorIdle).toBe(true); }); - it('goes idle again after a scrub resumes playback without the cursor moving', () => { + it('stays awake through a scrub and idles again once the cursor returns', () => { const { result, video } = renderPlayer(); act(() => result.current.handleVideoMouseMove()); startPlayback(video); act(() => vi.advanceTimersByTime(IDLE_DELAY_MS)); expect(result.current.cursorIdle).toBe(true); - // The element reports the pause the scrub asked for, then the resume. + // The timeline sits outside the player, so reaching it leaves the player + // first; the element then reports the pause the scrub asked for and the + // resume on release. + act(() => result.current.handleVideoMouseLeave()); act(() => result.current.handleTimelineMouseDown(mouseEventAt(50))); stopPlayback(video); - expect(result.current.cursorIdle).toBe(false); act(() => result.current.handleTimelineMouseUp()); startPlayback(video); + act(() => vi.advanceTimersByTime(IDLE_DELAY_MS * 5)); + expect(result.current.cursorIdle).toBe(false); + act(() => result.current.handleVideoMouseMove()); act(() => vi.advanceTimersByTime(IDLE_DELAY_MS)); expect(result.current.cursorIdle).toBe(true); }); @@ -466,7 +471,7 @@ describe('useVideoPlayer cursor idling', () => { expect(result.current.cursorIdle).toBe(true); }); - it('never idles while paused outside fullscreen', () => { + it('never idles while paused', () => { const { result } = renderPlayer(); act(() => result.current.handleVideoMouseMove()); @@ -484,15 +489,18 @@ describe('useVideoPlayer cursor idling', () => { expect(result.current.cursorIdle).toBe(false); }); - it('wakes as soon as playback pauses', () => { + it('stays idle across a pause and play the element emits on its own', () => { const { result, video } = renderPlayer(); act(() => result.current.handleVideoMouseMove()); startPlayback(video); act(() => vi.advanceTimersByTime(IDLE_DELAY_MS)); expect(result.current.cursorIdle).toBe(true); + // A rebuffer or a source switch pauses and resumes without any pointer + // activity, so the cursor must not come back for a second on every stall. stopPlayback(video); - expect(result.current.cursorIdle).toBe(false); + startPlayback(video); + expect(result.current.cursorIdle).toBe(true); }); });