1. Player.js — Added optional chaining (gamepad?.on/gamepad?.off) to prevent null crash
  2. GamepadProvider.tsx — Toast messages now use t('GAMEPAD_CONNECTED') / t('GAMEPAD_DISCONNECTED') via i18next, added keys to en-US.json
  3. useVerticalNavGamepadNavigation.tsx — Fixed event.nativeEvent?.spatialNavigationPrevented → event.spatialNavigationPrevented (native DOM events don't have nativeEvent)
  4. GamepadProvider.tsx — Changed connectedGamepads from useState to useRef to avoid rAF effect restart cycle, removed it from effect deps, simplified the enabled guard
This commit is contained in:
Timothy Z. 2026-04-28 17:09:02 +03:00
parent 0c1af71aa9
commit 93833d0cd1
4 changed files with 90 additions and 87 deletions

View file

@ -445,12 +445,12 @@ const Player = ({ urlParams, queryParams }) => {
}, [onSeekPrev, onSeekNext, onVolumeUp, onVolumeDown]);
React.useEffect(() => {
gamepad.on('buttonA', GAMEPAD_HANDLER_ID, onPlayPause);
gamepad.on('analog', GAMEPAD_HANDLER_ID, onGamepadSeekAndVol);
gamepad?.on('buttonA', GAMEPAD_HANDLER_ID, onPlayPause);
gamepad?.on('analog', GAMEPAD_HANDLER_ID, onGamepadSeekAndVol);
return () => {
gamepad.off('buttonA', GAMEPAD_HANDLER_ID);
gamepad.off('analog', GAMEPAD_HANDLER_ID);
gamepad?.off('buttonA', GAMEPAD_HANDLER_ID);
gamepad?.off('analog', GAMEPAD_HANDLER_ID);
};
}, [onPlayPause, onGamepadSeekAndVol]);

View file

@ -16,6 +16,7 @@ const Interface = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) =>
quitOnCloseToggle,
escExitFullscreenToggle,
hideSpoilersToggle,
gamepadSupportToggle,
} = useInterfaceOptions(profile);
return (
@ -50,6 +51,12 @@ const Interface = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) =>
{...hideSpoilersToggle}
/>
</Option>
<Option label={'SETTINGS_GAMEPAD'}>
<Toggle
tabIndex={-1}
{...gamepadSupportToggle}
/>
</Option>
</Section>
);
});

View file

@ -1,6 +1,7 @@
// Copyright (C) 2017-2025 Smart code 203358507
import React, { useEffect, useRef, useState, useCallback } from 'react';
import React, { useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import useToast from 'stremio/common/Toast/useToast';
import GamepadContext from './GamepadContext';
@ -10,8 +11,9 @@ const GamepadProvider: React.FC<{
enabled: boolean;
children: React.ReactNode;
}> = ({ enabled, children }) => {
const { t } = useTranslation();
const toast = useToast();
const [connectedGamepads, setConnectedGamepads] = useState<number>(0);
const connectedGamepads = useRef<number>(0);
const lastButtonState = useRef<number[]>([]);
const lastButtonPressedTime = useRef<number>(0);
const axisTimer = useRef<number>(0);
@ -53,7 +55,7 @@ const GamepadProvider: React.FC<{
// @ts-expect-error show() expects no arguments
toast.show({
type: 'info',
title: 'Gamepad detected',
title: t('GAMEPAD_CONNECTED'),
timeout: 4000,
});
};
@ -62,7 +64,7 @@ const GamepadProvider: React.FC<{
// @ts-expect-error show() expects no arguments
toast.show({
type: 'info',
title: 'Gamepad disconnected',
title: t('GAMEPAD_DISCONNECTED'),
timeout: 4000,
});
};
@ -82,101 +84,95 @@ const GamepadProvider: React.FC<{
}, [enabled]);
useEffect(() => {
if (typeof navigator.getGamepads !== 'function') return;
if (!enabled || typeof navigator.getGamepads !== 'function') return;
let animationFrameId: number;
if (enabled) {
const updateStatus = () => {
if (document.hasFocus()) {
const currentTime = Date.now();
const controllers = Array.from(navigator.getGamepads()).filter(
(gp) => gp !== null
) as Gamepad[];
const updateStatus = () => {
if (document.hasFocus()) {
const currentTime = Date.now();
const controllers = Array.from(navigator.getGamepads()).filter(
(gp) => gp !== null
) as Gamepad[];
if (controllers.length !== connectedGamepads) {
setConnectedGamepads(controllers.length);
connectedGamepads.current = controllers.length;
controllers.forEach((controller, index) => {
const buttonsState = controller.buttons.reduce(
(buttons, button, i) => buttons | (button.pressed ? 1 << i : 0),
0
);
const processButton =
currentTime - lastButtonPressedTime.current > 250;
if (
lastButtonState.current[index] !== buttonsState ||
processButton
) {
lastButtonPressedTime.current = currentTime;
lastButtonState.current[index] = buttonsState;
if (buttonsState & (1 << 0)) emit('buttonA');
if (buttonsState & (1 << 1)) emit('buttonB');
if (buttonsState & (1 << 2)) emit('buttonX');
if (buttonsState & (1 << 3)) emit('buttonY');
if (buttonsState & (1 << 4)) emit('buttonLT');
if (buttonsState & (1 << 5)) emit('buttonRT');
}
controllers.forEach((controller, index) => {
const buttonsState = controller.buttons.reduce(
(buttons, button, i) => buttons | (button.pressed ? 1 << i : 0),
0
);
const deadZone = 0.05;
const maxSpeed = 100;
let axisHandled = false;
const processButton =
currentTime - lastButtonPressedTime.current > 250;
if (controller.axes[0] < -deadZone) {
if (
lastButtonState.current[index] !== buttonsState ||
processButton
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[0]) * 2000)
) {
lastButtonPressedTime.current = currentTime;
lastButtonState.current[index] = buttonsState;
if (buttonsState & (1 << 0)) emit('buttonA');
if (buttonsState & (1 << 1)) emit('buttonB');
if (buttonsState & (1 << 2)) emit('buttonX');
if (buttonsState & (1 << 3)) emit('buttonY');
if (buttonsState & (1 << 4)) emit('buttonLT');
if (buttonsState & (1 << 5)) emit('buttonRT');
emit('analog', 'left');
axisHandled = true;
}
const deadZone = 0.05;
const maxSpeed = 100;
let axisHandled = false;
if (controller.axes[0] < -deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[0]) * 2000)
) {
emit('analog', 'left');
axisHandled = true;
}
}
if (controller.axes[0] > deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[0]) * 2000)
) {
emit('analog', 'right');
axisHandled = true;
}
if (controller.axes[0] > deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[0]) * 2000)
) {
emit('analog', 'right');
axisHandled = true;
}
}
if (controller.axes[1] < -deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[1]) * 2000)
) {
emit('analog', 'up');
axisHandled = true;
}
if (controller.axes[1] < -deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[1]) * 2000)
) {
emit('analog', 'up');
axisHandled = true;
}
}
if (controller.axes[1] > deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[1]) * 2000)
) {
emit('analog', 'down');
axisHandled = true;
}
}
if (controller.axes[1] > deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[1]) * 2000)
) {
emit('analog', 'down');
axisHandled = true;
}
}
if (axisHandled) axisTimer.current = currentTime;
});
}
animationFrameId = requestAnimationFrame(updateStatus);
};
if (axisHandled) axisTimer.current = currentTime;
});
}
animationFrameId = requestAnimationFrame(updateStatus);
}
};
animationFrameId = requestAnimationFrame(updateStatus);
return () => {
if (enabled) {
cancelAnimationFrame(animationFrameId);
}
cancelAnimationFrame(animationFrameId);
};
}, [connectedGamepads, enabled]);
}, [enabled]);
return (
<GamepadContext.Provider value={{ on, off }}>

View file

@ -28,8 +28,8 @@ const useVerticalGamepadNavigation = (sectionRef: React.RefObject<HTMLDivElement
elements[nextIndex]?.click();
};
const handleKeyDown = (event) => {
if (!event.nativeEvent?.spatialNavigationPrevented) {
const handleKeyDown = (event: KeyboardEvent) => {
if (!(event as any).spatialNavigationPrevented) {
switch (event.key) {
case 'Tab':
moveFocus('next');