mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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.
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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<number>(0);
|
||||
const [cursorIdle, setCursorIdle] = useState(false);
|
||||
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isCursorOverPlayerRef = useRef(false);
|
||||
const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const bunnyFrameCallbackIdRef = useRef<number | null>(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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user