From 4617d030aa55aa4789acabb275fab7ff3213ac97 Mon Sep 17 00:00:00 2001 From: Botzy Date: Mon, 24 Mar 2025 13:37:39 +0200 Subject: [PATCH 01/36] feat(GamepadProvider): added Gamepad context and provider --- src/App/App.js | 22 +-- src/services/GamepadContext/GamepadContext.ts | 8 ++ .../GamepadContext/GamepadProvider.tsx | 128 ++++++++++++++++++ src/services/GamepadContext/index.tsx | 7 + src/services/GamepadContext/useGamepad.tsx | 8 ++ src/services/index.js | 5 +- 6 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 src/services/GamepadContext/GamepadContext.ts create mode 100644 src/services/GamepadContext/GamepadProvider.tsx create mode 100644 src/services/GamepadContext/index.tsx create mode 100644 src/services/GamepadContext/useGamepad.tsx 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 }; From 3ef25d03468c1febe87ebc56b86d5cfc6a198d22 Mon Sep 17 00:00:00 2001 From: Botzy Date: Mon, 24 Mar 2025 14:37:29 +0200 Subject: [PATCH 02/36] feat(Player): added gamepad events handling --- src/routes/Player/Player.js | 103 +++++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 25 deletions(-) diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index c491fde7e..c45ed8fc7 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -7,7 +7,7 @@ const debounce = require('lodash.debounce'); const langs = require('langs'); const { useTranslation } = require('react-i18next'); const { useRouteFocused } = require('stremio-router'); -const { useServices } = require('stremio/services'); +const { useServices, useGamepad } = require('stremio/services'); const { onFileDrop, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS } = require('stremio/common'); const { HorizontalNavBar, Transition } = require('stremio/components'); const BufferingLoader = require('./BufferingLoader'); @@ -32,6 +32,7 @@ const Video = require('./Video'); const Player = ({ urlParams, queryParams }) => { const { t } = useTranslation(); const { chromecast, shell, core } = useServices(); + const gamepad = useGamepad(); const forceTranscoding = React.useMemo(() => { return queryParams.has('forceTranscoding'); }, [queryParams]); @@ -281,6 +282,77 @@ const Player = ({ urlParams, queryParams }) => { video.addLocalSubtitles(filename, buffer); }); + const onPlayPause = React.useCallback(() => { + if (!menusOpen && !nextVideoPopupOpen && video.state.paused !== null) { + if (video.state.paused) { + onPlayRequested(); + setSeeking(false); + } else { + onPauseRequested(); + } + } + }, [menusOpen, nextVideoPopupOpen, video.state.paused]); + + const onSeekPrev = React.useCallback((event) => { + if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) { + const seekDuration = event?.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration; + const seekTime = video.state.time - seekDuration; + setSeeking(true); + onSeekRequested(Math.max(seekTime, 0)); + } + }, [menusOpen, nextVideoPopupOpen, video.state.time]); + + const onSeekNext = React.useCallback((event) => { + if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) { + const seekDuration = event?.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration; + setSeeking(true); + onSeekRequested(video.state.time + seekDuration); + } + }, [menusOpen, nextVideoPopupOpen, video.state.time]); + + const onVolumeUp = React.useCallback(() => { + if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) { + onVolumeChangeRequested(Math.min(video.state.volume + 5, 200)); + } + }, [menusOpen, nextVideoPopupOpen, video.state.volume]); + + const onVolumeDown = React.useCallback(() => { + if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) { + onVolumeChangeRequested(Math.max(video.state.volume - 5, 0)); + } + }, [menusOpen, nextVideoPopupOpen, video.state.volume]); + + const onGamepadSeekAndVol = React.useCallback((axis) => { + switch(axis) { + case 'left': { + onSeekPrev(); + break; + } + case 'right': { + onSeekNext(); + break; + } + case 'up': { + onVolumeUp(); + break; + } + case 'down': { + onVolumeDown(); + break; + } + } + }, [onSeekPrev, onSeekNext, onVolumeUp, onVolumeDown]); + + React.useEffect(() => { + gamepad.on('buttonA', onPlayPause); + gamepad.on('analog', onGamepadSeekAndVol); + + return () => { + gamepad.off('buttonA', onPlayPause); + gamepad.off('analog', onGamepadSeekAndVol); + }; + }, [onPlayPause, onGamepadSeekAndVol]); + React.useEffect(() => { setError(null); video.unload(); @@ -475,46 +547,27 @@ const Player = ({ urlParams, queryParams }) => { const onKeyDown = (event) => { switch (event.code) { case 'Space': { - if (!menusOpen && !nextVideoPopupOpen && video.state.paused !== null) { - if (video.state.paused) { - onPlayRequested(); - setSeeking(false); - } else { - onPauseRequested(); - } - } + onPlayPause(); break; } case 'ArrowRight': { - if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) { - const seekDuration = event.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration; - setSeeking(true); - onSeekRequested(video.state.time + seekDuration); - } + onSeekNext(event); break; } case 'ArrowLeft': { - if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) { - const seekDuration = event.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration; - setSeeking(true); - onSeekRequested(video.state.time - seekDuration); - } + onSeekPrev(event); break; } case 'ArrowUp': { - if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) { - onVolumeChangeRequested(Math.min(video.state.volume + 5, 200)); - } + onVolumeUp(); break; } case 'ArrowDown': { - if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) { - onVolumeChangeRequested(Math.max(video.state.volume - 5, 0)); - } + onVolumeDown(); break; } From c97c41f11c4e86d9750ad4012cd345bb2b9f1b9c Mon Sep 17 00:00:00 2001 From: Botzy Date: Mon, 24 Mar 2025 16:55:07 +0200 Subject: [PATCH 03/36] feat(Settings): added gamepad support toggle --- src/routes/Settings/Settings.js | 11 +++++++++++ src/routes/Settings/useProfileSettingsInputs.js | 17 +++++++++++++++++ src/types/models/Ctx.d.ts | 1 + 3 files changed, 29 insertions(+) diff --git a/src/routes/Settings/Settings.js b/src/routes/Settings/Settings.js index fbaf32afc..ca87bbadd 100644 --- a/src/routes/Settings/Settings.js +++ b/src/routes/Settings/Settings.js @@ -31,6 +31,7 @@ const Settings = () => { const toast = useToast(); const { interfaceLanguageSelect, + gamepadSupportToggle, hideSpoilersToggle, subtitlesLanguageSelect, subtitlesSizeSelect, @@ -352,6 +353,16 @@ const Settings = () => { {...hideSpoilersToggle} /> +
+
+
{ t('SETTINGS_GAMEPAD') }
+
+ +
{ t('SETTINGS_NAV_PLAYER') }
diff --git a/src/routes/Settings/useProfileSettingsInputs.js b/src/routes/Settings/useProfileSettingsInputs.js index afad298ed..95da4bddd 100644 --- a/src/routes/Settings/useProfileSettingsInputs.js +++ b/src/routes/Settings/useProfileSettingsInputs.js @@ -32,6 +32,22 @@ const useProfileSettingsInputs = (profile) => { } }), [profile.settings]); + const gamepadSupportToggle = React.useMemo(() => ({ + checked: profile.settings.gamepadSupport, + onClick: () => { + core.transport.dispatch({ + action: 'Ctx', + args: { + action: 'UpdateSettings', + args: { + ...profile.settings, + gamepadSupport: !profile.settings.gamepadSupport + } + } + }); + } + }), [profile.settings]); + const hideSpoilersToggle = React.useMemo(() => ({ checked: profile.settings.hideSpoilers, onClick: () => { @@ -341,6 +357,7 @@ const useProfileSettingsInputs = (profile) => { }), [profile.settings]); return { interfaceLanguageSelect, + gamepadSupportToggle, hideSpoilersToggle, subtitlesLanguageSelect, subtitlesSizeSelect, diff --git a/src/types/models/Ctx.d.ts b/src/types/models/Ctx.d.ts index 47f18749f..6f641b3ad 100644 --- a/src/types/models/Ctx.d.ts +++ b/src/types/models/Ctx.d.ts @@ -22,6 +22,7 @@ type Settings = { escExitFullscreen: boolean, interfaceLanguage: string, hideSpoilers: boolean, + gamepadSupport: boolean, nextVideoNotificationDuration: number, playInBackground: boolean, playerType: string | null, From 5927b17e871d3edae0d154fd1c9dc4b615c1f76f Mon Sep 17 00:00:00 2001 From: Botzy Date: Mon, 24 Mar 2025 17:09:09 +0200 Subject: [PATCH 04/36] feat(GamesupportProvider): add enabled prop to start/stop gamepad support --- src/App/App.js | 11 ++++++++++- src/services/GamepadContext/GamepadProvider.tsx | 6 +++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/App/App.js b/src/App/App.js index 32930fffa..de8c09cc7 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -22,6 +22,7 @@ const App = () => { const { i18n } = useTranslation(); const shell = useShell(); const [windowHidden, setWindowHidden] = React.useState(false); + const [gamepadSupportEnabled, setGamepadSupportEnabled] = React.useState(false); const onPathNotMatch = React.useCallback(() => { return NotFound; }, []); @@ -133,6 +134,10 @@ const App = () => { i18n.changeLanguage(args.settings.interfaceLanguage); } + if (args?.settings?.gamepadSupport !== undefined) { + setGamepadSupportEnabled(args.settings.gamepadSupport); + } + if (args?.settings?.quitOnClose && windowHidden) { shell.send('quit'); } @@ -146,6 +151,10 @@ const App = () => { i18n.changeLanguage(state.profile.settings.interfaceLanguage); } + if (typeof state.profile.settings.gamepadSupport === 'boolean') { + setGamepadSupportEnabled(state.profile.settings.gamepadSupport); + } + if (state?.profile?.settings?.quitOnClose && windowHidden) { shell.send('quit'); } @@ -204,7 +213,7 @@ const App = () => { - + diff --git a/src/services/GamepadContext/GamepadProvider.tsx b/src/services/GamepadContext/GamepadProvider.tsx index 224894917..3e684c769 100644 --- a/src/services/GamepadContext/GamepadProvider.tsx +++ b/src/services/GamepadContext/GamepadProvider.tsx @@ -8,7 +8,8 @@ import GamepadContext from './GamepadContext'; type GamepadEventHandlers = Record void)[]>; -const GamepadProvider: React.FC<{ children: React.ReactNode }> = ({ +const GamepadProvider: React.FC<{ enabled: boolean; children: React.ReactNode }> = ({ + enabled, children, }) => { const [connectedGamepads, setConnectedGamepads] = useState(0); @@ -44,8 +45,7 @@ const GamepadProvider: React.FC<{ children: React.ReactNode }> = ({ let animationFrameId: number; const updateStatus = () => { - // TODO: add check if profile settings allows this feature - if (document.hasFocus()) { + if (enabled && document.hasFocus()) { const currentTime = Date.now(); const controllers = Array.from(navigator.getGamepads()).filter( (gp) => gp !== null From b36ebcb4c687e3dbbd2add5b7116b1a1e02f71e7 Mon Sep 17 00:00:00 2001 From: Botzy Date: Mon, 24 Mar 2025 17:10:37 +0200 Subject: [PATCH 05/36] feat(GamesupportProvider): improve logic for enabling gamepad support --- .../GamepadContext/GamepadProvider.tsx | 169 ++++++++++-------- 1 file changed, 91 insertions(+), 78 deletions(-) diff --git a/src/services/GamepadContext/GamepadProvider.tsx b/src/services/GamepadContext/GamepadProvider.tsx index 3e684c769..263b2b965 100644 --- a/src/services/GamepadContext/GamepadProvider.tsx +++ b/src/services/GamepadContext/GamepadProvider.tsx @@ -1,17 +1,12 @@ -import React, { - useEffect, - useRef, - useState, - useCallback, -} from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import GamepadContext from './GamepadContext'; type GamepadEventHandlers = Record void)[]>; -const GamepadProvider: React.FC<{ enabled: boolean; children: React.ReactNode }> = ({ - enabled, - children, -}) => { +const GamepadProvider: React.FC<{ + enabled: boolean; + children: React.ReactNode; +}> = ({ enabled, children }) => { const [connectedGamepads, setConnectedGamepads] = useState(0); const lastButtonState = useRef([]); const lastButtonPressedTime = useRef(0); @@ -43,80 +38,98 @@ const GamepadProvider: React.FC<{ enabled: boolean; children: React.ReactNode }> if (typeof navigator.getGamepads !== 'function') return; let animationFrameId: number; + if (enabled) { - const updateStatus = () => { - if (enabled && 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); + 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); + }; - 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); + } + + return () => { + if (enabled) { + cancelAnimationFrame(animationFrameId); + } }; - - animationFrameId = requestAnimationFrame(updateStatus); - - return () => cancelAnimationFrame(animationFrameId); - }, [connectedGamepads]); + }, [connectedGamepads, enabled]); return ( From 41865276d52224bfdedbcf480d50ab9a0f59fa20 Mon Sep 17 00:00:00 2001 From: Botzy Date: Tue, 25 Mar 2025 19:07:01 +0200 Subject: [PATCH 06/36] fix(GamepadSupport): fix issue with multiple handler events being fired --- src/routes/Player/Player.js | 10 +-- src/services/GamepadContext/GamepadContext.ts | 4 +- .../GamepadContext/GamepadProvider.tsx | 62 ++++++++++++++----- 3 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index c45ed8fc7..d44c24a25 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -29,6 +29,8 @@ const useVideo = require('./useVideo'); const styles = require('./styles'); const Video = require('./Video'); +const GAMEPAD_HANDLER_ID = 'player'; + const Player = ({ urlParams, queryParams }) => { const { t } = useTranslation(); const { chromecast, shell, core } = useServices(); @@ -344,12 +346,12 @@ const Player = ({ urlParams, queryParams }) => { }, [onSeekPrev, onSeekNext, onVolumeUp, onVolumeDown]); React.useEffect(() => { - gamepad.on('buttonA', onPlayPause); - gamepad.on('analog', onGamepadSeekAndVol); + gamepad.on('buttonA', GAMEPAD_HANDLER_ID, onPlayPause); + gamepad.on('analog', GAMEPAD_HANDLER_ID, onGamepadSeekAndVol); return () => { - gamepad.off('buttonA', onPlayPause); - gamepad.off('analog', onGamepadSeekAndVol); + gamepad.off('buttonA', GAMEPAD_HANDLER_ID); + gamepad.off('analog', GAMEPAD_HANDLER_ID); }; }, [onPlayPause, onGamepadSeekAndVol]); diff --git a/src/services/GamepadContext/GamepadContext.ts b/src/services/GamepadContext/GamepadContext.ts index d422b7153..c18a65dd6 100644 --- a/src/services/GamepadContext/GamepadContext.ts +++ b/src/services/GamepadContext/GamepadContext.ts @@ -1,8 +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; + on: (event: string, id: string, callback: (data?: any) => void) => void; + off: (event: string, id: string) => void; } | null>(null); export default GamepadContext; diff --git a/src/services/GamepadContext/GamepadProvider.tsx b/src/services/GamepadContext/GamepadProvider.tsx index 263b2b965..e60894c36 100644 --- a/src/services/GamepadContext/GamepadProvider.tsx +++ b/src/services/GamepadContext/GamepadProvider.tsx @@ -1,39 +1,73 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; +import useToast from 'stremio/common/Toast/useToast'; import GamepadContext from './GamepadContext'; -type GamepadEventHandlers = Record void)[]>; +type GamepadEventHandlers = Map void>>; const GamepadProvider: React.FC<{ enabled: boolean; children: React.ReactNode; }> = ({ enabled, children }) => { + const toast = useToast(); const [connectedGamepads, setConnectedGamepads] = useState(0); const lastButtonState = useRef([]); const lastButtonPressedTime = useRef(0); const axisTimer = useRef(0); - const eventHandlers = useRef({}); + const eventHandlers = useRef(new Map()); - const on = useCallback((event: string, callback: (data?: any) => void) => { - if (!eventHandlers.current[event]) { - eventHandlers.current[event] = []; + const on = useCallback((event: string, id: string, callback: (data?: any) => void) => { + if (!eventHandlers.current.has(event)) { + eventHandlers.current.set(event, new Map()); } - eventHandlers.current[event].push(callback); + + const handlers = eventHandlers.current.get(event)!; + + // Ensure only one handler per component + handlers.set(id, 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 off = useCallback((event: string, id: string) => { + eventHandlers.current.get(event)?.delete(id); }, []); const emit = (event: string, data?: any) => { - if (eventHandlers.current[event]) { - eventHandlers.current[event].forEach((callback) => callback(data)); + if (eventHandlers.current.has(event)) { + eventHandlers.current.get(event)!.forEach((callback) => callback(data)); } }; + const onGamepadConnected = () => { + // @ts-ignore + toast.show({ + type: 'info', + title: 'Gamepad detected', + timeout: 4000, + }); + }; + + const onGamepadDisconnected = () => { + // @ts-ignore + toast.show({ + type: 'info', + title: 'Gamepad disconnected', + timeout: 4000, + }); + }; + + useEffect(() => { + if (enabled) { + window.addEventListener('gamepadconnected', onGamepadConnected); + window.addEventListener('gamepaddisconnected', onGamepadDisconnected); + } + + return () => { + if (enabled) { + window.removeEventListener('gamepadconnected', onGamepadConnected); + window.removeEventListener('gamepaddisconnected', onGamepadDisconnected); + } + }; + }, [enabled]); + useEffect(() => { if (typeof navigator.getGamepads !== 'function') return; From 476f2f85517e787b9eb3ee951d983f6debf5bfdf Mon Sep 17 00:00:00 2001 From: Botzy Date: Wed, 26 Mar 2025 19:00:55 +0200 Subject: [PATCH 07/36] feat(useVerticalSpatialNav): added hook for enabling spatial nav on vertical navigation --- src/components/MainNavBars/MainNavBars.tsx | 10 +++- .../NavBar/VerticalNavBar/VerticalNavBar.js | 6 +- .../useSpatialNavigation.tsx | 56 +++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 src/services/SpatialNavigation/useSpatialNavigation.tsx diff --git a/src/components/MainNavBars/MainNavBars.tsx b/src/components/MainNavBars/MainNavBars.tsx index 43d08f5c8..fed1668a2 100644 --- a/src/components/MainNavBars/MainNavBars.tsx +++ b/src/components/MainNavBars/MainNavBars.tsx @@ -1,9 +1,10 @@ // Copyright (C) 2017-2023 Smart code 203358507 -import React, { memo } from 'react'; +import React, { memo, useEffect } from 'react'; import classnames from 'classnames'; import { VerticalNavBar, HorizontalNavBar } from 'stremio/components/NavBar'; import styles from './MainNavBars.less'; +import { useGamepad, useVerticalSpatialNavigation } from 'stremio/services'; const TABS = [ { id: 'board', label: 'Board', icon: 'home', href: '#/' }, @@ -21,7 +22,13 @@ type Props = { children?: React.ReactNode, }; +const GAMEPAD_HANDLER_ID = 'vertical-nav'; + const MainNavBars = memo(({ className, route, query, children }: Props) => { + const navRef = React.useRef(null); + + useVerticalSpatialNavigation(navRef, GAMEPAD_HANDLER_ID); + return (
{ navMenu={true} /> { +const VerticalNavBar = React.memo(React.forwardRef(({ className, selected, tabs }, ref) => { const { t } = useTranslation(); return ( -