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]); }, [onSeekPrev, onSeekNext, onVolumeUp, onVolumeDown]);
React.useEffect(() => { React.useEffect(() => {
gamepad.on('buttonA', GAMEPAD_HANDLER_ID, onPlayPause); gamepad?.on('buttonA', GAMEPAD_HANDLER_ID, onPlayPause);
gamepad.on('analog', GAMEPAD_HANDLER_ID, onGamepadSeekAndVol); gamepad?.on('analog', GAMEPAD_HANDLER_ID, onGamepadSeekAndVol);
return () => { return () => {
gamepad.off('buttonA', GAMEPAD_HANDLER_ID); gamepad?.off('buttonA', GAMEPAD_HANDLER_ID);
gamepad.off('analog', GAMEPAD_HANDLER_ID); gamepad?.off('analog', GAMEPAD_HANDLER_ID);
}; };
}, [onPlayPause, onGamepadSeekAndVol]); }, [onPlayPause, onGamepadSeekAndVol]);

View file

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

View file

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

View file

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