a resting mouse is not the user

The chrome came back for a couple of seconds every few minutes during a film with
nobody touching anything. Only three things wake it, and none of them is periodic —
so the culprit is a stray pointermove. A mouse sitting on a desk still emits the odd
one-pixel event, a bumped table emits a few, and the browser synthesises a zero-delta
move of its own when the cursor style changes, which this player does every single
time it hides the cursor.

A move now has to travel eight manhattan pixels from wherever the pointer last
genuinely woke it. The anchor only advances on a real wake, so a slow deliberate
drift still accumulates past the threshold — it is jitter around a fixed point that
stops counting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 02:01:04 +00:00
co-authored by Claude Opus 5
parent 541b2a509b
commit acff072f5c
@@ -40,6 +40,8 @@ import Hls from './vendor/hls.mjs';
const PROGRESS_INTERVAL_MS = 10_000;
const CHROME_HIDE_MS = 2_500;
/** Manhattan pixels a pointer must travel before it counts as the user, rather than as a resting mouse. */
const WAKE_THRESHOLD_PX = 8;
const VOLUME_KEY = 'jellyfin:volume';
type Mode = 'file' | 'hls-mse' | 'progressive';
@@ -110,6 +112,8 @@ export const VideoPlayer = ({ id }: { id: string }) => {
// re-run) every time one of them changes.
const audioRef = useRef<number | null>(null);
const bitrateRef = useRef<number | null>(null);
// Where the pointer was when it last counted as movement — see `onPointerMove`.
const pointerRef = useRef<{ x: number; y: number } | null>(null);
const mode = useMemo(() => (plan ? transportFor(plan) : null), [plan]);
const hasTimeline = mode !== null && mode !== 'progressive';
@@ -305,6 +309,25 @@ export const VideoPlayer = ({ id }: { id: string }) => {
const wake = () => setChrome(true);
/**
* Wake on real movement only.
*
* A bare `pointermove` handler is too credulous to sit in front of a two-hour film: a mouse resting on a
* desk still emits the odd one-pixel event, a bumped table emits a few, and a browser will synthesise a
* zero-delta move of its own when the cursor style changes — which this player does every time it hides the
* cursor. Any one of those read as "the user is here" and put the scrubber back for two and a half seconds,
* minutes apart, with nobody having touched anything.
*
* The anchor only moves when a wake actually happens, so a slow deliberate drift still accumulates past the
* threshold and wakes; it is jitter around a fixed point that never does.
*/
const onPointerMove = (ev: React.PointerEvent) => {
const last = pointerRef.current;
if (last && Math.abs(ev.clientX - last.x) + Math.abs(ev.clientY - last.y) < WAKE_THRESHOLD_PX) return;
pointerRef.current = { x: ev.clientX, y: ev.clientY };
wake();
};
// Keyboard on the container rather than on window: the player is a panel inside a workspace, and stealing
// space or the arrow keys from whatever else is on screen would be wrong. Autofocus puts it in reach.
const onKeyDown = (ev: React.KeyboardEvent) => {
@@ -355,7 +378,7 @@ export const VideoPlayer = ({ id }: { id: string }) => {
tabIndex={-1}
autoFocus
onKeyDown={onKeyDown}
onPointerMove={wake}
onPointerMove={onPointerMove}
onPointerDown={wake}
className={`absolute inset-0 z-30 bg-black outline-none ${idle ? 'cursor-none' : ''}`}
>