mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Merge pull request #75 from yusufipk/claude/openframe-mouse-overlay-bug-66603b
fix(player): re-arm the cursor idle timer when playback changes
This commit is contained in:
+2
-53
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { useCursorIdle } from '@/components/video-page/hooks/use-cursor-idle';
|
||||||
import Hls from 'hls.js';
|
import Hls from 'hls.js';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
@@ -128,8 +129,7 @@ export default function CompareVersionsPageClient({
|
|||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [cursorIdle, setCursorIdle] = useState(false);
|
const { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave } = useCursorIdle(isPlaying);
|
||||||
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Map of versionId -> YT.Player or Custom Adapter
|
// Map of versionId -> YT.Player or Custom Adapter
|
||||||
@@ -409,57 +409,6 @@ export default function CompareVersionsPageClient({
|
|||||||
handleSeek(currentTimeRef.current);
|
handleSeek(currentTimeRef.current);
|
||||||
}, [isDragging, handleSeek]);
|
}, [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)
|
// Keyboard shortcuts (matching video page)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -216,7 +216,6 @@ export function VideoPageContent({
|
|||||||
setActiveVersionId,
|
setActiveVersionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cursor idle detection: hide overlay when cursor idle for 3s while playing
|
|
||||||
// Memoize version selection handler to prevent recreating on each render
|
// Memoize version selection handler to prevent recreating on each render
|
||||||
const handleVersionSelect = useCallback(
|
const handleVersionSelect = useCallback(
|
||||||
(versionId: string) => {
|
(versionId: string) => {
|
||||||
|
|||||||
@@ -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<ReturnType<typeof setTimeout> | 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 };
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
resolveSkipAmount as resolveSkipAmountFor,
|
resolveSkipAmount as resolveSkipAmountFor,
|
||||||
timeFromClientX as timeFromClientXWithin,
|
timeFromClientX as timeFromClientXWithin,
|
||||||
} from '@/components/video-page/hooks/video-player-utils';
|
} from '@/components/video-page/hooks/video-player-utils';
|
||||||
|
import { useCursorIdle } from '@/components/video-page/hooks/use-cursor-idle';
|
||||||
|
|
||||||
interface UseVideoPlayerParams {
|
interface UseVideoPlayerParams {
|
||||||
activeVersion: Version | undefined;
|
activeVersion: Version | undefined;
|
||||||
@@ -114,8 +115,7 @@ export function useVideoPlayer({
|
|||||||
const previousVersionKeyRef = useRef<string | null>(null);
|
const previousVersionKeyRef = useRef<string | null>(null);
|
||||||
const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false);
|
const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false);
|
||||||
const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(0);
|
const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(0);
|
||||||
const [cursorIdle, setCursorIdle] = useState(false);
|
const { cursorIdle, handleVideoMouseMove, handleVideoMouseLeave } = useCursorIdle(isPlaying);
|
||||||
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const bunnyFrameCallbackIdRef = useRef<number | null>(null);
|
const bunnyFrameCallbackIdRef = useRef<number | null>(null);
|
||||||
const bunnyFrameSampleRef = useRef<{ mediaTime: number; presentedFrames: number } | null>(null);
|
const bunnyFrameSampleRef = useRef<{ mediaTime: number; presentedFrames: number } | null>(null);
|
||||||
@@ -212,30 +212,6 @@ export function useVideoPlayer({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [activeVersionId, bunnyViewportRef]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (isApiLoaded) return;
|
if (isApiLoaded) return;
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
describe('useVideoPlayer keyboard shortcuts', () => {
|
||||||
it('starts and stops playback on space', () => {
|
it('starts and stops playback on space', () => {
|
||||||
const { video } = renderPlayer();
|
const { video } = renderPlayer();
|
||||||
|
|||||||
Reference in New Issue
Block a user