From 5079af1c8ddcfcbccaf153a3b53633ce93b54b96 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Wed, 29 Apr 2026 15:57:40 +0300 Subject: [PATCH 1/6] refactor: support auto controller detection --- src/App/GamepadModal/GamepadDiagram.tsx | 91 +++++++++++++------ src/App/GamepadModal/GamepadModal.tsx | 64 +++++++++---- src/services/GamepadContext/GamepadContext.ts | 3 + .../GamepadContext/GamepadProvider.tsx | 33 ++++++- src/services/GamepadContext/index.tsx | 2 + 5 files changed, 143 insertions(+), 50 deletions(-) diff --git a/src/App/GamepadModal/GamepadDiagram.tsx b/src/App/GamepadModal/GamepadDiagram.tsx index 4cda3b966..10eefb23f 100644 --- a/src/App/GamepadModal/GamepadDiagram.tsx +++ b/src/App/GamepadModal/GamepadDiagram.tsx @@ -3,19 +3,56 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useGamepad } from 'stremio/services'; +import type { ControllerType } from 'stremio/services/GamepadContext'; import styles from './styles.less'; type ActiveButton = string | null; const CX = 400; -const BTN = { L1: 'L1', L2: 'L2', R1: 'R1', R2: 'R2' }; const ARROW = { UP: '↑', DOWN: '↓', LEFT: '←', RIGHT: '→' }; +type FaceLayout = { + top: { glyph: string; fontSize: number; weight: number }; + right: { glyph: string; fontSize: number; weight: number }; + bottom: { glyph: string; fontSize: number; weight: number }; + left: { glyph: string; fontSize: number; weight: number }; + lb: string; + rb: string; + lt: string; + rt: string; +}; + +const LAYOUTS: Record = { + playstation: { + top: { glyph: '△', fontSize: 12, weight: 400 }, + right: { glyph: '○', fontSize: 12, weight: 400 }, + bottom: { glyph: '✕', fontSize: 12, weight: 400 }, + left: { glyph: '□', fontSize: 12, weight: 400 }, + lb: 'L1', rb: 'R1', lt: 'L2', rt: 'R2', + }, + xbox: { + top: { glyph: 'Y', fontSize: 11, weight: 700 }, + right: { glyph: 'B', fontSize: 11, weight: 700 }, + bottom: { glyph: 'A', fontSize: 11, weight: 700 }, + left: { glyph: 'X', fontSize: 11, weight: 700 }, + lb: 'LB', rb: 'RB', lt: 'LT', rt: 'RT', + }, + generic: { + top: { glyph: '△', fontSize: 12, weight: 400 }, + right: { glyph: '○', fontSize: 12, weight: 400 }, + bottom: { glyph: '✕', fontSize: 12, weight: 400 }, + left: { glyph: '□', fontSize: 12, weight: 400 }, + lb: 'L1', rb: 'R1', lt: 'L2', rt: 'R2', + }, +}; + const GamepadDiagram = () => { const { t } = useTranslation(); const gamepad = useGamepad(); const [active, setActive] = useState(null); + const layout = LAYOUTS[gamepad?.controllerType ?? 'generic']; + useEffect(() => { let timeout: ReturnType; const flash = (button: string) => () => { @@ -24,12 +61,12 @@ const GamepadDiagram = () => { 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('buttonA', 'gamepad-diagram', flash('bottom')); + gamepad?.on('buttonB', 'gamepad-diagram', flash('right')); + gamepad?.on('buttonX', 'gamepad-diagram', flash('left')); + gamepad?.on('buttonY', 'gamepad-diagram', flash('top')); + gamepad?.on('buttonLT', 'gamepad-diagram', flash('lb')); + gamepad?.on('buttonRT', 'gamepad-diagram', flash('rb')); gamepad?.on('analog', 'gamepad-diagram', (dir) => dir && flash('stick-' + dir)()); gamepad?.on('analogRight', 'gamepad-diagram', (dir) => dir && flash('rstick-' + dir)()); @@ -83,12 +120,12 @@ const GamepadDiagram = () => { d={`M${CX - SX - 38},68 Q${CX - SX - 40},48 ${CX - SX - 28},42 L${CX - SX + 28},42 Q${CX - SX + 40},48 ${CX - SX + 38},68 Z`} fill={'url(#triggerGrad)'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.7'} /> - {BTN.L2} + {layout.lt} - {BTN.R2} + {layout.rt} { - + - {BTN.L1} + {layout.lb} - + - {BTN.R1} + {layout.rb} - - - + + + {layout.top.glyph} - - - + + + {layout.right.glyph} - - - + + + {layout.bottom.glyph} - - - + + + {layout.left.glyph} diff --git a/src/App/GamepadModal/GamepadModal.tsx b/src/App/GamepadModal/GamepadModal.tsx index dd213eca4..2f12c579a 100644 --- a/src/App/GamepadModal/GamepadModal.tsx +++ b/src/App/GamepadModal/GamepadModal.tsx @@ -6,22 +6,44 @@ import { useTranslation } from 'react-i18next'; import Icon from '@stremio/stremio-icons/react'; import { Button } from 'stremio/components'; import { useGamepad } from 'stremio/services'; +import type { ControllerType } from 'stremio/services/GamepadContext'; import GamepadDiagram from './GamepadDiagram'; import styles from './styles.less'; -const CROSS = '✕'; -const CIRCLE = '○'; -const TRIANGLE = '△'; -const SQUARE = '□'; -const L_STICK = 'L stick'; -const R_STICK = 'R stick'; -const L1 = 'L1'; -const R1 = 'R1'; const LEFT = '←'; const RIGHT = '→'; const UP = '↑'; const DOWN = '↓'; +type FaceLabels = { + bottom: string; + right: string; + left: string; + top: string; + lb: string; + rb: string; + lStick: string; + rStick: string; +}; + +const LABELS: Record = { + playstation: { + bottom: '✕', right: '○', left: '□', top: '△', + lb: 'L1', rb: 'R1', + lStick: 'L stick', rStick: 'R stick', + }, + xbox: { + bottom: 'A', right: 'B', left: 'X', top: 'Y', + lb: 'LB', rb: 'RB', + lStick: 'L stick', rStick: 'R stick', + }, + generic: { + bottom: '✕', right: '○', left: '□', top: '△', + lb: 'L1', rb: 'R1', + lStick: 'L stick', rStick: 'R stick', + }, +}; + type Props = { onClose: () => void, }; @@ -30,6 +52,8 @@ const GamepadModal = ({ onClose }: Props) => { const { t } = useTranslation(); const gamepad = useGamepad(); + const labels = LABELS[gamepad?.controllerType ?? 'generic']; + useEffect(() => { const onKeyDown = ({ key }: KeyboardEvent) => { key === 'Escape' && onClose(); @@ -65,37 +89,37 @@ const GamepadModal = ({ onClose }: Props) => {
{t('GAMEPAD_SECTION_NAVIGATION')}
- {L_STICK} + {labels.lStick} {t('GAMEPAD_ACTION_NAVIGATE')}
- {CROSS} + {labels.bottom} {t('GAMEPAD_ACTION_SELECT')}
- {CIRCLE} + {labels.right} {t('GAMEPAD_ACTION_BACK')}
- {TRIANGLE} + {labels.top} {t('GAMEPAD_ACTION_FULLSCREEN')}
- {SQUARE} + {labels.left} {t('GAMEPAD_ACTION_GUIDE')}
- {L1} + {labels.lb} {t('GAMEPAD_ACTION_PREV_TAB')}
- {R1} + {labels.rb} {t('GAMEPAD_ACTION_NEXT_TAB')}
@@ -104,27 +128,27 @@ const GamepadModal = ({ onClose }: Props) => {
{t('GAMEPAD_SECTION_PLAYER')}
- {SQUARE} + {labels.left} {t('GAMEPAD_ACTION_PLAY_PAUSE')}
- {R_STICK} + {labels.rStick} {LEFT} {t('GAMEPAD_ACTION_SEEK_BACK')}
- {R_STICK} + {labels.rStick} {RIGHT} {t('GAMEPAD_ACTION_SEEK_FWD')}
- {R_STICK} + {labels.rStick} {UP} {t('GAMEPAD_ACTION_VOL_UP')}
- {R_STICK} + {labels.rStick} {DOWN} {t('GAMEPAD_ACTION_VOL_DOWN')}
diff --git a/src/services/GamepadContext/GamepadContext.ts b/src/services/GamepadContext/GamepadContext.ts index c156230aa..90bf5f915 100644 --- a/src/services/GamepadContext/GamepadContext.ts +++ b/src/services/GamepadContext/GamepadContext.ts @@ -2,9 +2,12 @@ import { createContext } from 'react'; +export type ControllerType = 'playstation' | 'xbox' | 'generic'; + const GamepadContext = createContext<{ on: (event: string, id: string, callback: (data?: string) => void) => void; off: (event: string, id: string) => void; + controllerType: ControllerType; } | null>(null); export default GamepadContext; diff --git a/src/services/GamepadContext/GamepadProvider.tsx b/src/services/GamepadContext/GamepadProvider.tsx index a957f0cfd..369770a88 100644 --- a/src/services/GamepadContext/GamepadProvider.tsx +++ b/src/services/GamepadContext/GamepadProvider.tsx @@ -1,9 +1,10 @@ // Copyright (C) 2017-2026 Smart code 203358507 -import React, { useEffect, useRef, useCallback } from 'react'; +import React, { useEffect, useRef, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import useToast from 'stremio/common/Toast/useToast'; import GamepadContext from './GamepadContext'; +import type { ControllerType } from './GamepadContext'; type GamepadEventHandlers = Map void>>; @@ -13,6 +14,17 @@ type GamepadProviderProps = { children: React.ReactNode; }; +const detectControllerType = (gamepad: Gamepad): ControllerType => { + const id = gamepad.id.toLowerCase(); + // Sony vendor id 054c — DualShock / DualSense / generic PlayStation + if (/sony|playstation|dualsense|dualshock|054c/.test(id)) return 'playstation'; + // Microsoft vendor id 045e — Xbox / XInput + if (/xbox|microsoft|xinput|045e/.test(id)) return 'xbox'; + // Browser "Standard Gamepad" mapping mirrors the Xbox layout + if (gamepad.mapping === 'standard') return 'xbox'; + return 'generic'; +}; + const GamepadProvider = ({ enabled, onGuide, children }: GamepadProviderProps) => { const { t } = useTranslation(); const toast = useToast(); @@ -22,6 +34,7 @@ const GamepadProvider = ({ enabled, onGuide, children }: GamepadProviderProps) = const axisTimer = useRef(0); const axisTimerRight = useRef(0); const eventHandlers = useRef(new Map()); + const [controllerType, setControllerType] = useState('generic'); const on = useCallback((event: string, id: string, callback: (data?: string) => void) => { if (!eventHandlers.current.has(event)) { @@ -55,7 +68,8 @@ const GamepadProvider = ({ enabled, onGuide, children }: GamepadProviderProps) = } }; - const onGamepadConnected = useCallback(() => { + const onGamepadConnected = useCallback((e: GamepadEvent) => { + setControllerType(detectControllerType(e.gamepad)); // @ts-expect-error show() expects no arguments toast.show({ type: 'info', @@ -65,6 +79,10 @@ const GamepadProvider = ({ enabled, onGuide, children }: GamepadProviderProps) = }, [toast, t]); const onGamepadDisconnected = useCallback(() => { + const remaining = Array.from(navigator.getGamepads()).filter( + (gp) => gp !== null + ) as Gamepad[]; + setControllerType(remaining.length > 0 ? detectControllerType(remaining[0]) : 'generic'); // @ts-expect-error show() expects no arguments toast.show({ type: 'info', @@ -76,6 +94,15 @@ const GamepadProvider = ({ enabled, onGuide, children }: GamepadProviderProps) = useEffect(() => { if (!enabled) return; + if (typeof navigator.getGamepads === 'function') { + const existing = Array.from(navigator.getGamepads()).filter( + (gp) => gp !== null + ) as Gamepad[]; + if (existing.length > 0) { + setControllerType(detectControllerType(existing[0])); + } + } + window.addEventListener('gamepadconnected', onGamepadConnected); window.addEventListener('gamepaddisconnected', onGamepadDisconnected); @@ -217,7 +244,7 @@ const GamepadProvider = ({ enabled, onGuide, children }: GamepadProviderProps) = }, [enabled]); return ( - + {children} ); diff --git a/src/services/GamepadContext/index.tsx b/src/services/GamepadContext/index.tsx index 3c6cca72f..f520f5be7 100644 --- a/src/services/GamepadContext/index.tsx +++ b/src/services/GamepadContext/index.tsx @@ -3,6 +3,8 @@ import GamepadProvider from './GamepadProvider'; import useGamepad from './useGamepad'; +export type { ControllerType } from './GamepadContext'; + export { GamepadProvider, useGamepad From c47a06c667742253f9deba81181a54562b738693 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Wed, 29 Apr 2026 16:18:12 +0300 Subject: [PATCH 2/6] refactor: correctl change layout xbox controller --- src/App/GamepadModal/GamepadDiagram.tsx | 31 ++++++++++++++++++------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/App/GamepadModal/GamepadDiagram.tsx b/src/App/GamepadModal/GamepadDiagram.tsx index 10eefb23f..20df63418 100644 --- a/src/App/GamepadModal/GamepadDiagram.tsx +++ b/src/App/GamepadModal/GamepadDiagram.tsx @@ -91,6 +91,19 @@ const GamepadDiagram = () => { const STX = 75; const BY = 30; + // Xbox controllers are asymmetric — left stick sits upper-left (where the + // d-pad is on PlayStation) and the d-pad drops to the lower-left. + const isXbox = (gamepad?.controllerType ?? 'generic') === 'xbox'; + const lstickPos = isXbox + ? { cx: CX - BX, cy: 148 + BY } + : { cx: CX - STX, cy: 240 + BY }; + const dpadPos = isXbox + ? { cx: CX - STX, cy: 240 + BY } + : { cx: CX - BX, cy: 149 + BY }; + const navLine = isXbox + ? { x1: CX - BX - 24, y1: 148 + BY } + : { x1: CX - STX - 24, y1: 232 + BY }; + return ( @@ -190,16 +203,16 @@ const GamepadDiagram = () => { {layout.left.glyph} - - + + - - - - - - + + + + + + @@ -216,7 +229,7 @@ const GamepadDiagram = () => { - + From f70af4efda1ad363f66637825e360ad3fa628861 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Wed, 29 Apr 2026 16:27:02 +0300 Subject: [PATCH 3/6] fix: stuck on board issue --- src/components/MainNavBars/MainNavBars.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/MainNavBars/MainNavBars.tsx b/src/components/MainNavBars/MainNavBars.tsx index 03e314b93..1dae27a16 100644 --- a/src/components/MainNavBars/MainNavBars.tsx +++ b/src/components/MainNavBars/MainNavBars.tsx @@ -26,8 +26,9 @@ const MainNavBars = memo(({ className, route, query, children }: Props) => { const navRef = React.useRef(null); const contentRef = React.useRef(null); - useContentGamepadNavigation(contentRef, route ?? 'board'); - useVerticalNavGamepadNavigation(navRef, route ?? 'board'); + const navRoute = route === 'continue_watching' ? 'library' : (route ?? ''); + useContentGamepadNavigation(contentRef, navRoute); + useVerticalNavGamepadNavigation(navRef, navRoute); return (
From b7655e658fc04a8f1cab152ebe2f9d16a29e4ee5 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Wed, 29 Apr 2026 16:28:39 +0300 Subject: [PATCH 4/6] refactor: correctly focus player buttons --- src/routes/Player/ControlBar/ControlBar.js | 22 +++++++++---------- .../Player/ControlBar/SeekBar/SeekBar.js | 2 +- .../useContentGamepadNavigation.tsx | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/routes/Player/ControlBar/ControlBar.js b/src/routes/Player/ControlBar/ControlBar.js index a58acc14f..c9ba5cd7e 100644 --- a/src/routes/Player/ControlBar/ControlBar.js +++ b/src/routes/Player/ControlBar/ControlBar.js @@ -117,18 +117,18 @@ const ControlBar = React.forwardRef(({ onSeekRequested={onSeekRequested} />
- { nextVideo !== null ? - : null } -
- - - - - { metaItem?.content?.videos?.length > 0 ? - : null } - -
diff --git a/src/routes/Player/ControlBar/SeekBar/SeekBar.js b/src/routes/Player/ControlBar/SeekBar/SeekBar.js index f60902a28..3140bf5d1 100644 --- a/src/routes/Player/ControlBar/SeekBar/SeekBar.js +++ b/src/routes/Player/ControlBar/SeekBar/SeekBar.js @@ -59,7 +59,7 @@ const SeekBar = ({ className, time, duration, buffered, onSeekRequested }) => { onSlide={onSlide} onComplete={onComplete} /> -