From b2070c10303a65b53af63ac5878161ea6080b73a Mon Sep 17 00:00:00 2001 From: yusufipk Date: Tue, 8 Sep 2026 12:55:30 +0300 Subject: [PATCH 1/2] fix(player): re-arm the cursor idle timer when playback changes The idle countdown that hides the cursor and the play/pause overlay was only started from mousemove. A cursor that stayed still over the player while a click, a key or a scrub release started playback never got a countdown, so the overlay stayed on the video until the mouse moved again. Arm the timer from one place and rerun it whenever playback or fullscreen changes, keeping the cursor-over-player state in a ref so the same rule applies from every entry point. --- components/video-page-content.tsx | 1 - .../video-page/hooks/use-video-player.ts | 42 ++++++---- .../component/hooks/use-video-player.test.ts | 78 +++++++++++++++++++ 3 files changed, 107 insertions(+), 14 deletions(-) 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(); From 7357f24831ba0dbe042671fb1f48823afe4c98b4 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Tue, 8 Sep 2026 13:17:34 +0300 Subject: [PATCH 2/2] refactor(player): share the cursor idle hook and stop waking on element pauses Move the cursor idle logic into useCursorIdle and use it from both the video page and the compare page, which carried its own copy. Only pointer activity wakes the cursor now: a pause/play pair the element emits on its own (a rebuffer, a source switch) leaves the idle state alone instead of bringing the chrome back for a second. The fullscreen-while-paused arming is gone, nothing rendered it. The scrub test now leaves the player before pressing the timeline, as the real layout forces. --- .../compare/compare-versions-page-client.tsx | 55 +------------------ .../video-page/hooks/use-cursor-idle.ts | 52 ++++++++++++++++++ .../video-page/hooks/use-video-player.ts | 44 +-------------- .../component/hooks/use-video-player.test.ts | 20 +++++-- 4 files changed, 70 insertions(+), 101 deletions(-) create mode 100644 components/video-page/hooks/use-cursor-idle.ts 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); }); });