diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 75a943b..844e382 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -214,7 +214,6 @@ export function VideoPageContent({ setActiveVersionId, }); - // Cursor idle detection: hide overlay when cursor idle for 3s while playing // Memoize version selection handler to prevent recreating on each render const handleVersionSelect = useCallback( (versionId: string) => { diff --git a/components/video-page/hooks/use-video-player.ts b/components/video-page/hooks/use-video-player.ts index a32280a..9af3571 100644 --- a/components/video-page/hooks/use-video-player.ts +++ b/components/video-page/hooks/use-video-player.ts @@ -34,6 +34,9 @@ import { 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; + interface UseVideoPlayerParams { activeVersion: Version | undefined; activeVersionId: string | null; @@ -116,6 +119,7 @@ export function useVideoPlayer({ const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState(0); const [cursorIdle, setCursorIdle] = useState(false); const cursorIdleTimerRef = useRef | null>(null); + const isCursorOverPlayerRef = useRef(false); const bunnyRetryTimerRef = useRef | null>(null); const bunnyFrameCallbackIdRef = useRef(null); const bunnyFrameSampleRef = useRef<{ mediaTime: number; presentedFrames: number } | null>(null); @@ -212,29 +216,41 @@ export function useVideoPlayer({ return () => observer.disconnect(); }, [activeVersionId, bunnyViewportRef]); - const handleVideoMouseMove = useCallback(() => { - setCursorIdle(false); + // 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); - const shouldHideControls = isFullscreenMode; + if (!isCursorOverPlayerRef.current) return; + if (!isPlaying && !isFullscreenMode) return; - if (isPlaying || shouldHideControls) { - cursorIdleTimerRef.current = setTimeout(() => { - setCursorIdle(true); - }, 1000); - } + cursorIdleTimerRef.current = setTimeout(() => { + setCursorIdle(true); + }, CURSOR_IDLE_DELAY_MS); }, [isFullscreenMode, isPlaying]); - const handleVideoMouseLeave = useCallback(() => { - if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); - setCursorIdle(false); - }, []); + 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 cf1d638..9847e1e 100644 --- a/tests/component/hooks/use-video-player.test.ts +++ b/tests/component/hooks/use-video-player.test.ts @@ -418,6 +418,84 @@ describe('useVideoPlayer scrubbing', () => { }); }); +describe('useVideoPlayer cursor idling', () => { + /** The hook hides the cursor and the overlay after this much stillness. */ + const IDLE_DELAY_MS = 1000; + + it('goes idle when playback starts under a cursor that never moves again', () => { + const { result, video } = renderPlayer(); + act(() => result.current.handleVideoMouseMove()); + + startPlayback(video); + expect(result.current.cursorIdle).toBe(false); + + act(() => vi.advanceTimersByTime(IDLE_DELAY_MS)); + expect(result.current.cursorIdle).toBe(true); + }); + + it('goes idle again after a scrub resumes playback without the cursor moving', () => { + 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. + 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)); + expect(result.current.cursorIdle).toBe(true); + }); + + it('wakes on movement and idles again after the same delay', () => { + const { result, video } = renderPlayer(); + act(() => result.current.handleVideoMouseMove()); + startPlayback(video); + act(() => vi.advanceTimersByTime(IDLE_DELAY_MS)); + + act(() => result.current.handleVideoMouseMove()); + expect(result.current.cursorIdle).toBe(false); + + act(() => vi.advanceTimersByTime(IDLE_DELAY_MS - 1)); + expect(result.current.cursorIdle).toBe(false); + act(() => vi.advanceTimersByTime(1)); + expect(result.current.cursorIdle).toBe(true); + }); + + it('never idles while paused outside fullscreen', () => { + const { result } = renderPlayer(); + act(() => result.current.handleVideoMouseMove()); + + act(() => vi.advanceTimersByTime(IDLE_DELAY_MS * 5)); + expect(result.current.cursorIdle).toBe(false); + }); + + it('stays awake once the cursor has left the player', () => { + const { result, video } = renderPlayer(); + act(() => result.current.handleVideoMouseMove()); + act(() => result.current.handleVideoMouseLeave()); + + startPlayback(video); + act(() => vi.advanceTimersByTime(IDLE_DELAY_MS * 5)); + expect(result.current.cursorIdle).toBe(false); + }); + + it('wakes as soon as playback pauses', () => { + const { result, video } = renderPlayer(); + act(() => result.current.handleVideoMouseMove()); + startPlayback(video); + act(() => vi.advanceTimersByTime(IDLE_DELAY_MS)); + expect(result.current.cursorIdle).toBe(true); + + stopPlayback(video); + expect(result.current.cursorIdle).toBe(false); + }); +}); + describe('useVideoPlayer keyboard shortcuts', () => { it('starts and stops playback on space', () => { const { video } = renderPlayer();