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