From f95273b8cea7067c93b536df3d4145ada096d9d2 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Tue, 28 Apr 2026 22:31:33 +0300 Subject: [PATCH] feat: add controller guide --- src/App/App.js | 12 +- src/App/GamepadModal/GamepadDiagram.tsx | 224 ++++++++++++++++++ src/App/GamepadModal/GamepadModal.tsx | 126 ++++++++++ src/App/GamepadModal/index.ts | 4 + src/App/GamepadModal/styles.less | 174 ++++++++++++++ src/common/Shortcuts/shortcuts.json | 5 + src/services/GamepadContext/GamepadContext.ts | 2 +- .../GamepadContext/GamepadProvider.tsx | 14 +- src/services/GamepadContext/index.tsx | 2 + src/services/GamepadContext/useGamepad.tsx | 2 +- src/services/GamepadNavigation/index.tsx | 2 + .../useContentGamepadNavigation.tsx | 2 +- .../useHorizontalNavGamepadNavigation.tsx | 2 +- .../useVerticalNavGamepadNavigation.tsx | 52 ++-- 14 files changed, 579 insertions(+), 44 deletions(-) create mode 100644 src/App/GamepadModal/GamepadDiagram.tsx create mode 100644 src/App/GamepadModal/GamepadModal.tsx create mode 100644 src/App/GamepadModal/index.ts create mode 100644 src/App/GamepadModal/styles.less diff --git a/src/App/App.js b/src/App/App.js index 04ca718ba..732e948d7 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -12,6 +12,7 @@ const DeepLinkHandler = require('./DeepLinkHandler'); const SearchParamsHandler = require('./SearchParamsHandler'); const { default: UpdaterBanner } = require('./UpdaterBanner'); const { default: ShortcutsModal } = require('./ShortcutsModal'); +const { default: GamepadModal } = require('./GamepadModal'); const ErrorDialog = require('./ErrorDialog'); const withProtectedRoutes = require('./withProtectedRoutes'); const routerViewsConfig = require('./routerViewsConfig'); @@ -41,12 +42,16 @@ const App = () => { }, []); const [initialized, setInitialized] = React.useState(false); const [shortcutModalOpen,, closeShortcutsModal, toggleShortcutModal] = useBinaryState(false); + const [gamepadModalOpen,, closeGamepadModal, toggleGamepadModal] = useBinaryState(false); const onShortcut = React.useCallback((name) => { if (name === 'shortcuts') { toggleShortcutModal(); } - }, [toggleShortcutModal]); + if (name === 'gamepadGuide') { + toggleGamepadModal(); + } + }, [toggleShortcutModal, toggleGamepadModal]); React.useEffect(() => { let prevPath = window.location.hash.slice(1); @@ -222,11 +227,14 @@ const App = () => { - + { shortcutModalOpen && } + { + gamepadModalOpen && + } diff --git a/src/App/GamepadModal/GamepadDiagram.tsx b/src/App/GamepadModal/GamepadDiagram.tsx new file mode 100644 index 000000000..35a93e013 --- /dev/null +++ b/src/App/GamepadModal/GamepadDiagram.tsx @@ -0,0 +1,224 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useGamepad } from 'stremio/services'; +import styles from './styles.less'; + +type ActiveButton = string | null; + +const CX = 400; + +const GamepadDiagram = () => { + const { t } = useTranslation(); + const gamepad = useGamepad(); + const [active, setActive] = useState(null); + + useEffect(() => { + let timeout: ReturnType; + const flash = (button: string) => () => { + setActive(button); + clearTimeout(timeout); + timeout = setTimeout(() => setActive(null), 400); + }; + + gamepad?.on('buttonA', 'gamepad-diagram', flash('cross')); + gamepad?.on('buttonB', 'gamepad-diagram', flash('circle')); + gamepad?.on('buttonX', 'gamepad-diagram', flash('square')); + gamepad?.on('buttonY', 'gamepad-diagram', flash('triangle')); + gamepad?.on('buttonLT', 'gamepad-diagram', flash('l1')); + gamepad?.on('buttonRT', 'gamepad-diagram', flash('r1')); + gamepad?.on('analog', 'gamepad-diagram', (dir: string) => flash('stick-' + dir)()); + + return () => { + clearTimeout(timeout); + gamepad?.off('buttonA', 'gamepad-diagram'); + gamepad?.off('buttonB', 'gamepad-diagram'); + gamepad?.off('buttonX', 'gamepad-diagram'); + gamepad?.off('buttonY', 'gamepad-diagram'); + gamepad?.off('buttonLT', 'gamepad-diagram'); + gamepad?.off('buttonRT', 'gamepad-diagram'); + gamepad?.off('analog', 'gamepad-diagram'); + }; + }, [gamepad]); + + const glow = (id: string) => active === id ? '#7b5bf5' : undefined; + const glowOp = (id: string) => active === id ? 1 : undefined; + + const SX = 130; + const BX = 120; + const STX = 75; + const BY = 30; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + {/* ===== L2 / R2 TRIGGERS (drawn first, behind everything) ===== */} + + L2 + + R2 + + {/* ===== CONTROLLER BODY ===== */} + + + {/* ===== TOUCHPAD ===== */} + + + {/* ===== L1 / R1 BUMPERS (on top of body, on shoulder edge) ===== */} + + + L1 + + + + R1 + + + {/* ===== FACE BUTTONS (right, centered at CX+BX) ===== */} + {/* △ */} + + + + + {/* ○ */} + + + + + {/* ✕ */} + + + + + {/* □ */} + + + + + + {/* ===== D-PAD (left, mirrored at CX-BX) — cosmetic only ===== */} + + + + {/* ===== LEFT STICK (CX-STX) ===== */} + + + + + + + + + + {/* ===== RIGHT STICK (CX+STX) ===== */} + + + + {/* ============================= */} + {/* ===== LABELS — LEFT ===== */} + {/* ============================= */} + + {/* L1 */} + + + {t('GAMEPAD_ACTION_PREV_TAB')} + + {/* Left stick */} + + + {t('GAMEPAD_ACTION_NAVIGATE')} + {t('GAMEPAD_LABEL_STICK_PLAYER')} + + {/* □ Square */} + + + {t('GAMEPAD_ACTION_GUIDE')} + + {/* ============================= */} + {/* ===== LABELS — RIGHT ===== */} + {/* ============================= */} + + {/* R1 */} + + + {t('GAMEPAD_ACTION_NEXT_TAB')} + + {/* △ Triangle */} + + + {t('GAMEPAD_ACTION_FULLSCREEN')} + + {/* ○ Circle */} + + + {t('GAMEPAD_ACTION_BACK')} + + {/* ✕ Cross */} + + + {t('GAMEPAD_ACTION_SELECT')} + {t('GAMEPAD_LABEL_PLAY_PAUSE_PLAYER')} + + {/* Compat note */} + {t('GAMEPAD_LABEL_COMPAT')} + + ); +}; + +export default GamepadDiagram; diff --git a/src/App/GamepadModal/GamepadModal.tsx b/src/App/GamepadModal/GamepadModal.tsx new file mode 100644 index 000000000..cdd624fbf --- /dev/null +++ b/src/App/GamepadModal/GamepadModal.tsx @@ -0,0 +1,126 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import React, { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import Icon from '@stremio/stremio-icons/react'; +import { Button } from 'stremio/components'; +import { useGamepad } from 'stremio/services'; +import GamepadDiagram from './GamepadDiagram'; +import styles from './styles.less'; + +type Props = { + onClose: () => void, +}; + +const GamepadModal = ({ onClose }: Props) => { + const { t } = useTranslation(); + const gamepad = useGamepad(); + + useEffect(() => { + const onKeyDown = ({ key }: KeyboardEvent) => { + key === 'Escape' && onClose(); + }; + + document.addEventListener('keydown', onKeyDown); + gamepad?.on('buttonB', 'gamepad-modal', onClose); + return () => { + document.removeEventListener('keydown', onKeyDown); + gamepad?.off('buttonB', 'gamepad-modal'); + }; + }, [gamepad]); + + return createPortal(( +
+
+ +
+
+
+ {t('GAMEPAD_CONTROLS_TITLE')} +
+ + +
+ +
+ + +
+
+
{t('GAMEPAD_SECTION_NAVIGATION')}
+
+ L stick + + {t('GAMEPAD_ACTION_NAVIGATE')} +
+
+ + + {t('GAMEPAD_ACTION_SELECT')} +
+
+ + + {t('GAMEPAD_ACTION_BACK')} +
+
+ + + {t('GAMEPAD_ACTION_FULLSCREEN')} +
+
+ + + {t('GAMEPAD_ACTION_GUIDE')} +
+
+ L1 + + {t('GAMEPAD_ACTION_PREV_TAB')} +
+
+ R1 + + {t('GAMEPAD_ACTION_NEXT_TAB')} +
+
+ +
+
{t('GAMEPAD_SECTION_PLAYER')}
+
+ + + {t('GAMEPAD_ACTION_PLAY_PAUSE')} +
+
+ L stick + {'←'} + {t('GAMEPAD_ACTION_SEEK_BACK')} +
+
+ L stick + {'→'} + {t('GAMEPAD_ACTION_SEEK_FWD')} +
+
+ L stick + {'↑'} + {t('GAMEPAD_ACTION_VOL_UP')} +
+
+ L stick + {'↓'} + {t('GAMEPAD_ACTION_VOL_DOWN')} +
+
+
+
+
+
+ ), document.body); +}; + +export default GamepadModal; diff --git a/src/App/GamepadModal/index.ts b/src/App/GamepadModal/index.ts new file mode 100644 index 000000000..7ea9c5b43 --- /dev/null +++ b/src/App/GamepadModal/index.ts @@ -0,0 +1,4 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import GamepadModal from './GamepadModal'; +export default GamepadModal; diff --git a/src/App/GamepadModal/styles.less b/src/App/GamepadModal/styles.less new file mode 100644 index 000000000..870c4c92d --- /dev/null +++ b/src/App/GamepadModal/styles.less @@ -0,0 +1,174 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; + +.gamepad-modal { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + + .backdrop { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: @color-background-dark5-40; + cursor: pointer; + } + + .container { + position: relative; + display: flex; + flex-direction: column; + gap: 1rem; + max-height: 90%; + max-width: 72rem; + width: 92%; + border-radius: var(--border-radius); + background-color: var(--modal-background-color); + box-shadow: var(--outer-glow); + overflow-y: auto; + + .header { + flex: none; + display: flex; + justify-content: space-between; + align-items: center; + height: 5rem; + padding-left: 2.5rem; + padding-right: 1rem; + + .title { + position: relative; + font-size: 1.5rem; + font-weight: 500; + color: var(--primary-foreground-color); + } + + .close-button { + position: relative; + width: 3rem; + height: 3rem; + padding: 0.5rem; + border-radius: var(--border-radius); + z-index: 2; + + .icon { + display: block; + width: 100%; + height: 100%; + color: var(--primary-foreground-color); + opacity: 0.4; + } + + &:hover, &:focus { + .icon { + opacity: 1; + color: var(--primary-foreground-color); + } + } + + &:focus { + outline-color: var(--primary-foreground-color); + } + } + } + + .content { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 3rem; + padding: 0 3rem; + padding-bottom: 3rem; + overflow-y: auto; + } + } + + .diagram { + width: 100%; + max-width: 48rem; + height: auto; + } + + .sections { + display: flex; + flex-direction: row; + gap: 5rem; + width: 100%; + max-width: 56rem; + } + + .section { + flex: 1; + display: flex; + flex-direction: column; + gap: 1.2rem; + } + + .section-title { + font-size: 1rem; + font-weight: 600; + color: var(--primary-foreground-color); + margin-bottom: 0.4rem; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.7; + } + + .mapping { + display: grid; + grid-template-columns: auto 1.2rem 1fr; + align-items: center; + column-gap: 0.5rem; + } + + .kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + height: 1.8rem; + padding: 0 0.5rem; + border-radius: 0.35rem; + background-color: rgba(123, 91, 245, 0.15); + border: 1px solid rgba(123, 91, 245, 0.3); + box-shadow: none; + color: #c4b5fd; + font-size: 0.8rem; + font-weight: 600; + font-family: inherit; + } + + .dir { + color: #8b7faa; + font-size: 1rem; + } + + .action { + color: var(--primary-foreground-color); + font-size: 0.9rem; + opacity: 0.8; + } +} + +@media only screen and (max-width: 640px) { + .gamepad-modal { + .container { + max-width: 95%; + } + + .sections { + flex-direction: column; + gap: 2rem; + } + } +} diff --git a/src/common/Shortcuts/shortcuts.json b/src/common/Shortcuts/shortcuts.json index 659c3c373..536b9f7f7 100644 --- a/src/common/Shortcuts/shortcuts.json +++ b/src/common/Shortcuts/shortcuts.json @@ -27,6 +27,11 @@ "name": "shortcuts", "label": "SETTINGS_SHORTCUT_SHORTCUTS", "combos": [["Ctrl", "/"]] + }, + { + "name": "gamepadGuide", + "label": "GAMEPAD_ACTION_GUIDE", + "combos": [["Ctrl", "G"]] } ] }, diff --git a/src/services/GamepadContext/GamepadContext.ts b/src/services/GamepadContext/GamepadContext.ts index 54e6afccb..6e5025d39 100644 --- a/src/services/GamepadContext/GamepadContext.ts +++ b/src/services/GamepadContext/GamepadContext.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2017-2025 Smart code 203358507 +// Copyright (C) 2017-2026 Smart code 203358507 import { createContext } from 'react'; diff --git a/src/services/GamepadContext/GamepadProvider.tsx b/src/services/GamepadContext/GamepadProvider.tsx index 3fc315a08..d86517bfb 100644 --- a/src/services/GamepadContext/GamepadProvider.tsx +++ b/src/services/GamepadContext/GamepadProvider.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2017-2025 Smart code 203358507 +// Copyright (C) 2017-2026 Smart code 203358507 import React, { useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -9,8 +9,9 @@ type GamepadEventHandlers = Map void>>; const GamepadProvider: React.FC<{ enabled: boolean; + onGuide?: () => void; children: React.ReactNode; -}> = ({ enabled, children }) => { +}> = ({ enabled, onGuide, children }) => { const { t } = useTranslation(); const toast = useToast(); const connectedGamepads = useRef(0); @@ -83,6 +84,15 @@ const GamepadProvider: React.FC<{ }; }, [enabled]); + useEffect(() => { + if (onGuide) { + on('buttonX', 'gamepad-guide', onGuide); + } + return () => { + off('buttonX', 'gamepad-guide'); + }; + }, [onGuide]); + useEffect(() => { if (!enabled || typeof navigator.getGamepads !== 'function') return; diff --git a/src/services/GamepadContext/index.tsx b/src/services/GamepadContext/index.tsx index aac999a2f..3c6cca72f 100644 --- a/src/services/GamepadContext/index.tsx +++ b/src/services/GamepadContext/index.tsx @@ -1,3 +1,5 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + import GamepadProvider from './GamepadProvider'; import useGamepad from './useGamepad'; diff --git a/src/services/GamepadContext/useGamepad.tsx b/src/services/GamepadContext/useGamepad.tsx index 4753e2f81..d3cf91b8d 100644 --- a/src/services/GamepadContext/useGamepad.tsx +++ b/src/services/GamepadContext/useGamepad.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2017-2025 Smart code 203358507 +// Copyright (C) 2017-2026 Smart code 203358507 import { useContext } from 'react'; import GamepadContext from './GamepadContext'; diff --git a/src/services/GamepadNavigation/index.tsx b/src/services/GamepadNavigation/index.tsx index aee93d4ff..2c3199b0d 100644 --- a/src/services/GamepadNavigation/index.tsx +++ b/src/services/GamepadNavigation/index.tsx @@ -1,3 +1,5 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + import useContentGamepadNavigation from './useContentGamepadNavigation'; import useVerticalNavGamepadNavigation from './useVerticalNavGamepadNavigation'; import useHorizontalNavGamepadNavigation from './useHorizontalNavGamepadNavigation'; diff --git a/src/services/GamepadNavigation/useContentGamepadNavigation.tsx b/src/services/GamepadNavigation/useContentGamepadNavigation.tsx index e2823730f..c9406f2fa 100644 --- a/src/services/GamepadNavigation/useContentGamepadNavigation.tsx +++ b/src/services/GamepadNavigation/useContentGamepadNavigation.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2017-2025 Smart code 203358507 +// Copyright (C) 2017-2026 Smart code 203358507 import { useEffect } from 'react'; import { useGamepad } from '../GamepadContext'; diff --git a/src/services/GamepadNavigation/useHorizontalNavGamepadNavigation.tsx b/src/services/GamepadNavigation/useHorizontalNavGamepadNavigation.tsx index 42042e40c..422489e3e 100644 --- a/src/services/GamepadNavigation/useHorizontalNavGamepadNavigation.tsx +++ b/src/services/GamepadNavigation/useHorizontalNavGamepadNavigation.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2017-2025 Smart code 203358507 +// Copyright (C) 2017-2026 Smart code 203358507 import { useEffect } from 'react'; import { useGamepad } from '../GamepadContext'; diff --git a/src/services/GamepadNavigation/useVerticalNavGamepadNavigation.tsx b/src/services/GamepadNavigation/useVerticalNavGamepadNavigation.tsx index a076a6d53..4f3b8d379 100644 --- a/src/services/GamepadNavigation/useVerticalNavGamepadNavigation.tsx +++ b/src/services/GamepadNavigation/useVerticalNavGamepadNavigation.tsx @@ -1,55 +1,35 @@ -// Copyright (C) 2017-2025 Smart code 203358507 +// Copyright (C) 2017-2026 Smart code 203358507 import { useEffect } from 'react'; import { useGamepad } from '../GamepadContext'; -const useVerticalGamepadNavigation = (sectionRef: React.RefObject, gamepadHandlerId: string) => { +const ROUTES = ['board', 'discover', 'library', 'calendar', 'addons', 'settings']; + +const useVerticalGamepadNavigation = (_sectionRef: React.RefObject, currentRoute: string) => { const gamepad = useGamepad(); useEffect(() => { - const focusableSelector = 'a'; - const focusableElements = () => - Array.from(sectionRef.current?.querySelectorAll(focusableSelector) || []); - - const moveFocus = (direction: 'prev' | 'next') => { - const route = window.location.hash.replace('#/', '') || 'board'; - const elements = focusableElements(); - if (!elements.length || route !== gamepadHandlerId) return; - - const currentIndex = elements.findIndex((item) => item.classList.contains('selected')); + const navigate = (direction: 'prev' | 'next') => { + const currentIndex = ROUTES.indexOf(currentRoute); + if (currentIndex === -1) return; let nextIndex = currentIndex; + if (direction === 'next') nextIndex = Math.min(currentIndex + 1, ROUTES.length - 1); + if (direction === 'prev') nextIndex = Math.max(currentIndex - 1, 0); - if (direction === 'next') - nextIndex = (elements.length + currentIndex + 1) % elements.length; - if (direction === 'prev') - nextIndex = (elements.length + currentIndex - 1) % elements.length; - - elements[nextIndex]?.click(); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (!(event as any).spatialNavigationPrevented) { - switch (event.key) { - case 'Tab': - moveFocus('next'); - break; - default: - break; - } + if (nextIndex !== currentIndex) { + document.dispatchEvent(new KeyboardEvent('keydown', { key: String(nextIndex + 1), code: `Digit${nextIndex + 1}`, bubbles: true })); } }; - document.addEventListener('keydown', handleKeyDown); - gamepad?.on('buttonLT', gamepadHandlerId, () => moveFocus('prev')); - gamepad?.on('buttonRT', gamepadHandlerId, () => moveFocus('next')); + gamepad?.on('buttonLT', currentRoute, () => navigate('prev')); + gamepad?.on('buttonRT', currentRoute, () => navigate('next')); return () => { - document.removeEventListener('keydown', handleKeyDown); - gamepad?.off('buttonLT', gamepadHandlerId); - gamepad?.off('buttonRT', gamepadHandlerId); + gamepad?.off('buttonLT', currentRoute); + gamepad?.off('buttonRT', currentRoute); }; - }, [gamepad, sectionRef]); + }, [gamepad, currentRoute]); }; export default useVerticalGamepadNavigation;