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,10 +84,9 @@ 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()) {
@ -94,9 +95,7 @@ const GamepadProvider: React.FC<{
(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) => { controllers.forEach((controller, index) => {
const buttonsState = controller.buttons.reduce( const buttonsState = controller.buttons.reduce(
@ -169,14 +168,11 @@ const GamepadProvider: React.FC<{
}; };
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');