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-content.tsx b/components/video-page-content.tsx index 11a72d8..7a4c232 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -216,7 +216,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-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 a32280a..ac18b3e 100644 --- a/components/video-page/hooks/use-video-player.ts +++ b/components/video-page/hooks/use-video-player.ts @@ -33,6 +33,7 @@ import { resolveSkipAmount as resolveSkipAmountFor, timeFromClientX as timeFromClientXWithin, } from '@/components/video-page/hooks/video-player-utils'; +import { useCursorIdle } from '@/components/video-page/hooks/use-cursor-idle'; interface UseVideoPlayerParams { activeVersion: Version | undefined; @@ -114,8 +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 { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave } = useCursorIdle(isPlaying); const bunnyRetryTimerRef = useRef | null>(null); const bunnyFrameCallbackIdRef = useRef(null); const bunnyFrameSampleRef = useRef<{ mediaTime: number; presentedFrames: number } | null>(null); @@ -212,30 +212,6 @@ export function useVideoPlayer({ return () => observer.disconnect(); }, [activeVersionId, bunnyViewportRef]); - const handleVideoMouseMove = useCallback(() => { - setCursorIdle(false); - if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); - - const shouldHideControls = isFullscreenMode; - - if (isPlaying || shouldHideControls) { - cursorIdleTimerRef.current = setTimeout(() => { - setCursorIdle(true); - }, 1000); - } - }, [isFullscreenMode, isPlaying]); - - const handleVideoMouseLeave = useCallback(() => { - if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); - setCursorIdle(false); - }, []); - - useEffect(() => { - return () => { - if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); - }; - }, []); - 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..0212afe 100644 --- a/tests/component/hooks/use-video-player.test.ts +++ b/tests/component/hooks/use-video-player.test.ts @@ -418,6 +418,92 @@ 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('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 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); + 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); + }); + + 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', () => { + 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('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); + startPlayback(video); + expect(result.current.cursorIdle).toBe(true); + }); +}); + describe('useVideoPlayer keyboard shortcuts', () => { it('starts and stops playback on space', () => { const { video } = renderPlayer();