diff --git a/src/App/App.js b/src/App/App.js index 803515b09..32930fffa 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -4,7 +4,7 @@ require('spatial-navigation-polyfill'); const React = require('react'); const { useTranslation } = require('react-i18next'); const { Router } = require('stremio-router'); -const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services'); +const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider, GamepadProvider } = require('stremio/services'); const { NotFound } = require('stremio/routes'); const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender, useShell } = require('stremio/common'); const ServicesToaster = require('./ServicesToaster'); @@ -204,15 +204,17 @@ const App = () => { - - - - - + + + + + + + diff --git a/src/services/GamepadContext/GamepadContext.ts b/src/services/GamepadContext/GamepadContext.ts new file mode 100644 index 000000000..d422b7153 --- /dev/null +++ b/src/services/GamepadContext/GamepadContext.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react'; + +const GamepadContext = createContext<{ + on: (event: string, callback: (data?: any) => void) => void; + off: (event: string, callback: (data?: any) => void) => void; +} | null>(null); + +export default GamepadContext; diff --git a/src/services/GamepadContext/GamepadProvider.tsx b/src/services/GamepadContext/GamepadProvider.tsx new file mode 100644 index 000000000..224894917 --- /dev/null +++ b/src/services/GamepadContext/GamepadProvider.tsx @@ -0,0 +1,128 @@ +import React, { + useEffect, + useRef, + useState, + useCallback, +} from 'react'; +import GamepadContext from './GamepadContext'; + +type GamepadEventHandlers = Record void)[]>; + +const GamepadProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [connectedGamepads, setConnectedGamepads] = useState(0); + const lastButtonState = useRef([]); + const lastButtonPressedTime = useRef(0); + const axisTimer = useRef(0); + const eventHandlers = useRef({}); + + const on = useCallback((event: string, callback: (data?: any) => void) => { + if (!eventHandlers.current[event]) { + eventHandlers.current[event] = []; + } + eventHandlers.current[event].push(callback); + }, []); + + const off = useCallback((event: string, callback: (data?: any) => void) => { + if (eventHandlers.current[event]) { + eventHandlers.current[event] = eventHandlers.current[event].filter( + (cb) => cb !== callback + ); + } + }, []); + + const emit = (event: string, data?: any) => { + if (eventHandlers.current[event]) { + eventHandlers.current[event].forEach((callback) => callback(data)); + } + }; + + useEffect(() => { + if (typeof navigator.getGamepads !== 'function') return; + + let animationFrameId: number; + + const updateStatus = () => { + // TODO: add check if profile settings allows this feature + 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); + } + + 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'); + } + + 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[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 (axisHandled) axisTimer.current = currentTime; + }); + } + animationFrameId = requestAnimationFrame(updateStatus); + }; + + animationFrameId = requestAnimationFrame(updateStatus); + + return () => cancelAnimationFrame(animationFrameId); + }, [connectedGamepads]); + + return ( + + {children} + + ); +}; + +export default GamepadProvider; diff --git a/src/services/GamepadContext/index.tsx b/src/services/GamepadContext/index.tsx new file mode 100644 index 000000000..aac999a2f --- /dev/null +++ b/src/services/GamepadContext/index.tsx @@ -0,0 +1,7 @@ +import GamepadProvider from './GamepadProvider'; +import useGamepad from './useGamepad'; + +export { + GamepadProvider, + useGamepad +}; diff --git a/src/services/GamepadContext/useGamepad.tsx b/src/services/GamepadContext/useGamepad.tsx new file mode 100644 index 000000000..14970bbff --- /dev/null +++ b/src/services/GamepadContext/useGamepad.tsx @@ -0,0 +1,8 @@ +import { useContext } from 'react'; +import GamepadContext from './GamepadContext'; + +const useGamepad = () => { + return useContext(GamepadContext); +}; + +export default useGamepad; diff --git a/src/services/index.js b/src/services/index.js index 84cfcc8b8..f591c6e75 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -5,6 +5,7 @@ const Core = require('./Core'); const DragAndDrop = require('./DragAndDrop'); const KeyboardShortcuts = require('./KeyboardShortcuts'); const { ServicesProvider, useServices } = require('./ServicesContext'); +const { GamepadProvider, useGamepad } = require('./GamepadContext'); const Shell = require('./Shell'); module.exports = { @@ -14,5 +15,7 @@ module.exports = { KeyboardShortcuts, ServicesProvider, useServices, - Shell + Shell, + GamepadProvider, + useGamepad };