mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-05-10 03:01:45 +00:00
Merge pull request #1239 from Stremio/refactor/gamepad-detection
GamePad: support auto controller detection
This commit is contained in:
commit
2c578bf40a
9 changed files with 188 additions and 77 deletions
|
|
@ -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<ControllerType, FaceLayout> = {
|
||||
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<ActiveButton>(null);
|
||||
|
||||
const layout = LAYOUTS[gamepad?.controllerType ?? 'generic'];
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
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)());
|
||||
|
||||
|
|
@ -54,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 (
|
||||
<svg className={styles['diagram']} viewBox={'0 0 800 510'} xmlns={'http://www.w3.org/2000/svg'}>
|
||||
<defs>
|
||||
|
|
@ -83,12 +133,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'}
|
||||
/>
|
||||
<text x={CX - SX} y={'58'} textAnchor={'middle'} fill={'#8b7faa'} fontSize={'8'} fontWeight={'500'}>{BTN.L2}</text>
|
||||
<text x={CX - SX} y={'58'} textAnchor={'middle'} fill={'#8b7faa'} fontSize={'8'} fontWeight={'500'}>{layout.lt}</text>
|
||||
<path
|
||||
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'}
|
||||
/>
|
||||
<text x={CX + SX} y={'58'} textAnchor={'middle'} fill={'#8b7faa'} fontSize={'8'} fontWeight={'500'}>{BTN.R2}</text>
|
||||
<text x={CX + SX} y={'58'} textAnchor={'middle'} fill={'#8b7faa'} fontSize={'8'} fontWeight={'500'}>{layout.rt}</text>
|
||||
</g>
|
||||
<path
|
||||
className={styles['anim-body']}
|
||||
|
|
@ -119,50 +169,50 @@ const GamepadDiagram = () => {
|
|||
|
||||
<g className={styles['anim-controls']}>
|
||||
<rect x={CX - 58} y={96 + BY} rx={'8'} ry={'8'} width={'116'} height={'48'} fill={'#1e1a35'} stroke={'#3d3660'} strokeWidth={'1.5'} />
|
||||
<g filter={active === 'l1' ? 'url(#glow)' : undefined}>
|
||||
<g filter={active === 'lb' ? 'url(#glow)' : undefined}>
|
||||
<path
|
||||
d={`M${CX - SX - 40},74 Q${CX - SX - 38},66 ${CX - SX - 30},64 L${CX - SX + 30},64 Q${CX - SX + 38},66 ${CX - SX + 40},74 L${CX - SX + 36},82 Q${CX - SX + 34},85 ${CX - SX + 28},85 L${CX - SX - 28},85 Q${CX - SX - 34},85 ${CX - SX - 36},82 Z`}
|
||||
fill={'url(#bumperGrad)'} stroke={glow('l1') || '#5848a0'} strokeWidth={'1.2'} opacity={glowOp('l1') || 0.9}
|
||||
fill={'url(#bumperGrad)'} stroke={glow('lb') || '#5848a0'} strokeWidth={'1.2'} opacity={glowOp('lb') || 0.9}
|
||||
/>
|
||||
<text x={CX - SX} y={'78'} textAnchor={'middle'} fill={'#a89ecc'} fontSize={'9'} fontWeight={'600'}>{BTN.L1}</text>
|
||||
<text x={CX - SX} y={'78'} textAnchor={'middle'} fill={'#a89ecc'} fontSize={'9'} fontWeight={'600'}>{layout.lb}</text>
|
||||
</g>
|
||||
<g filter={active === 'r1' ? 'url(#glow)' : undefined}>
|
||||
<g filter={active === 'rb' ? 'url(#glow)' : undefined}>
|
||||
<path
|
||||
d={`M${CX + SX - 40},74 Q${CX + SX - 38},66 ${CX + SX - 30},64 L${CX + SX + 30},64 Q${CX + SX + 38},66 ${CX + SX + 40},74 L${CX + SX + 36},82 Q${CX + SX + 34},85 ${CX + SX + 28},85 L${CX + SX - 28},85 Q${CX + SX - 34},85 ${CX + SX - 36},82 Z`}
|
||||
fill={'url(#bumperGrad)'} stroke={glow('r1') || '#5848a0'} strokeWidth={'1.2'} opacity={glowOp('r1') || 0.9}
|
||||
fill={'url(#bumperGrad)'} stroke={glow('rb') || '#5848a0'} strokeWidth={'1.2'} opacity={glowOp('rb') || 0.9}
|
||||
/>
|
||||
<text x={CX + SX} y={'78'} textAnchor={'middle'} fill={'#a89ecc'} fontSize={'9'} fontWeight={'600'}>{BTN.R1}</text>
|
||||
<text x={CX + SX} y={'78'} textAnchor={'middle'} fill={'#a89ecc'} fontSize={'9'} fontWeight={'600'}>{layout.rb}</text>
|
||||
</g>
|
||||
|
||||
<g filter={active === 'triangle' ? 'url(#glow)' : undefined}>
|
||||
<circle cx={CX + BX} cy={118 + BY} r={'15'} fill={'#1e1a35'} stroke={glow('triangle') || '#5848a0'} strokeWidth={'1.5'} />
|
||||
<text x={CX + BX} y={123 + BY} textAnchor={'middle'} fill={active === 'triangle' ? '#fff' : '#a89ecc'} fontSize={'12'}>△</text>
|
||||
<g filter={active === 'top' ? 'url(#glow)' : undefined}>
|
||||
<circle cx={CX + BX} cy={118 + BY} r={'15'} fill={'#1e1a35'} stroke={glow('top') || '#5848a0'} strokeWidth={'1.5'} />
|
||||
<text x={CX + BX} y={123 + BY} textAnchor={'middle'} fill={active === 'top' ? '#fff' : '#a89ecc'} fontSize={layout.top.fontSize} fontWeight={layout.top.weight}>{layout.top.glyph}</text>
|
||||
</g>
|
||||
|
||||
<g filter={active === 'circle' ? 'url(#glow)' : undefined}>
|
||||
<circle cx={CX + BX + 30} cy={148 + BY} r={'15'} fill={'#1e1a35'} stroke={glow('circle') || '#5848a0'} strokeWidth={'1.5'} />
|
||||
<text x={CX + BX + 30} y={153 + BY} textAnchor={'middle'} fill={active === 'circle' ? '#fff' : '#a89ecc'} fontSize={'12'}>○</text>
|
||||
<g filter={active === 'right' ? 'url(#glow)' : undefined}>
|
||||
<circle cx={CX + BX + 30} cy={148 + BY} r={'15'} fill={'#1e1a35'} stroke={glow('right') || '#5848a0'} strokeWidth={'1.5'} />
|
||||
<text x={CX + BX + 30} y={153 + BY} textAnchor={'middle'} fill={active === 'right' ? '#fff' : '#a89ecc'} fontSize={layout.right.fontSize} fontWeight={layout.right.weight}>{layout.right.glyph}</text>
|
||||
</g>
|
||||
|
||||
<g filter={active === 'cross' ? 'url(#glow)' : undefined}>
|
||||
<circle cx={CX + BX} cy={178 + BY} r={'15'} fill={active === 'cross' ? '#9b7fff' : '#7b5bf5'} stroke={'#9b7fff'} strokeWidth={'1.5'} />
|
||||
<text x={CX + BX} y={183 + BY} textAnchor={'middle'} fill={'#fff'} fontSize={'12'}>✕</text>
|
||||
<g filter={active === 'bottom' ? 'url(#glow)' : undefined}>
|
||||
<circle cx={CX + BX} cy={178 + BY} r={'15'} fill={active === 'bottom' ? '#9b7fff' : '#7b5bf5'} stroke={'#9b7fff'} strokeWidth={'1.5'} />
|
||||
<text x={CX + BX} y={183 + BY} textAnchor={'middle'} fill={'#fff'} fontSize={layout.bottom.fontSize} fontWeight={layout.bottom.weight}>{layout.bottom.glyph}</text>
|
||||
</g>
|
||||
|
||||
<g filter={active === 'square' ? 'url(#glow)' : undefined}>
|
||||
<circle cx={CX + BX - 30} cy={148 + BY} r={'15'} fill={'#1e1a35'} stroke={glow('square') || '#5848a0'} strokeWidth={'1.5'} />
|
||||
<text x={CX + BX - 30} y={153 + BY} textAnchor={'middle'} fill={active === 'square' ? '#fff' : '#a89ecc'} fontSize={'12'}>□</text>
|
||||
<g filter={active === 'left' ? 'url(#glow)' : undefined}>
|
||||
<circle cx={CX + BX - 30} cy={148 + BY} r={'15'} fill={'#1e1a35'} stroke={glow('left') || '#5848a0'} strokeWidth={'1.5'} />
|
||||
<text x={CX + BX - 30} y={153 + BY} textAnchor={'middle'} fill={active === 'left' ? '#fff' : '#a89ecc'} fontSize={layout.left.fontSize} fontWeight={layout.left.weight}>{layout.left.glyph}</text>
|
||||
</g>
|
||||
<rect x={CX - BX - 12} y={120 + BY} rx={'3'} ry={'3'} width={'24'} height={'58'} fill={'#1e1a35'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.4'} />
|
||||
<rect x={CX - BX - 29} y={137 + BY} rx={'3'} ry={'3'} width={'58'} height={'24'} fill={'#1e1a35'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.4'} />
|
||||
<rect x={dpadPos.cx - 12} y={dpadPos.cy - 29} rx={'3'} ry={'3'} width={'24'} height={'58'} fill={'#1e1a35'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.4'} />
|
||||
<rect x={dpadPos.cx - 29} y={dpadPos.cy - 12} rx={'3'} ry={'3'} width={'58'} height={'24'} fill={'#1e1a35'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.4'} />
|
||||
|
||||
<g filter={active?.startsWith('stick-') ? 'url(#glow)' : undefined}>
|
||||
<circle cx={CX - STX} cy={240 + BY} r={'26'} fill={'#1a1530'} stroke={active?.startsWith('stick-') ? '#7b5bf5' : '#3d3660'} strokeWidth={'2'} />
|
||||
<circle cx={CX - STX} cy={240 + BY} r={'17'} fill={'#252040'} stroke={'#4a4075'} strokeWidth={'1.5'} />
|
||||
<text x={CX - STX} y={232 + BY} textAnchor={'middle'} fill={active === 'stick-up' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-up' ? '700' : '400'}>↑</text>
|
||||
<text x={CX - STX} y={253 + BY} textAnchor={'middle'} fill={active === 'stick-down' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-down' ? '700' : '400'}>↓</text>
|
||||
<text x={CX - STX - 11} y={244 + BY} textAnchor={'middle'} fill={active === 'stick-left' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-left' ? '700' : '400'}>←</text>
|
||||
<text x={CX - STX + 11} y={244 + BY} textAnchor={'middle'} fill={active === 'stick-right' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-right' ? '700' : '400'}>→</text>
|
||||
<circle cx={lstickPos.cx} cy={lstickPos.cy} r={'26'} fill={'#1a1530'} stroke={active?.startsWith('stick-') ? '#7b5bf5' : '#3d3660'} strokeWidth={'2'} />
|
||||
<circle cx={lstickPos.cx} cy={lstickPos.cy} r={'17'} fill={'#252040'} stroke={'#4a4075'} strokeWidth={'1.5'} />
|
||||
<text x={lstickPos.cx} y={lstickPos.cy - 8} textAnchor={'middle'} fill={active === 'stick-up' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-up' ? '700' : '400'}>↑</text>
|
||||
<text x={lstickPos.cx} y={lstickPos.cy + 13} textAnchor={'middle'} fill={active === 'stick-down' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-down' ? '700' : '400'}>↓</text>
|
||||
<text x={lstickPos.cx - 11} y={lstickPos.cy + 4} textAnchor={'middle'} fill={active === 'stick-left' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-left' ? '700' : '400'}>←</text>
|
||||
<text x={lstickPos.cx + 11} y={lstickPos.cy + 4} textAnchor={'middle'} fill={active === 'stick-right' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-right' ? '700' : '400'}>→</text>
|
||||
</g>
|
||||
|
||||
<g filter={active?.startsWith('rstick-') ? 'url(#glow)' : undefined}>
|
||||
|
|
@ -179,7 +229,7 @@ const GamepadDiagram = () => {
|
|||
<g className={styles['anim-lines']}>
|
||||
<line x1={CX - SX - 40} y1={'74'} x2={'85'} y2={'48'} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
|
||||
<circle cx={'85'} cy={'48'} r={'2'} fill={'#5848a0'} />
|
||||
<line x1={CX - STX - 24} y1={232 + BY} x2={'85'} y2={168} stroke={'#7b5bf5'} strokeWidth={'1'} opacity={'0.4'} />
|
||||
<line x1={navLine.x1} y1={navLine.y1} x2={'85'} y2={168} stroke={'#7b5bf5'} strokeWidth={'1'} opacity={'0.4'} />
|
||||
<circle cx={'85'} cy={168} r={'2'} fill={'#7b5bf5'} />
|
||||
<line x1={CX + BX - 44} y1={148 + BY} x2={'85'} y2={248} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.35'} />
|
||||
<circle cx={'85'} cy={248} r={'2'} fill={'#5848a0'} />
|
||||
|
|
|
|||
|
|
@ -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<ControllerType, FaceLabels> = {
|
||||
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();
|
||||
|
|
@ -44,7 +68,7 @@ const GamepadModal = ({ onClose }: Props) => {
|
|||
}, [gamepad]);
|
||||
|
||||
return createPortal((
|
||||
<div className={styles['gamepad-modal']}>
|
||||
<div className={styles['gamepad-modal']} data-gamepad-modal>
|
||||
<div className={styles['backdrop']} onClick={onClose} />
|
||||
|
||||
<div className={styles['container']}>
|
||||
|
|
@ -65,37 +89,37 @@ const GamepadModal = ({ onClose }: Props) => {
|
|||
<div className={styles['section']}>
|
||||
<div className={styles['section-title']}>{t('GAMEPAD_SECTION_NAVIGATION')}</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>{L_STICK}</kbd>
|
||||
<kbd className={styles['kbd']}>{labels.lStick}</kbd>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_NAVIGATE')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>{CROSS}</kbd>
|
||||
<kbd className={styles['kbd']}>{labels.bottom}</kbd>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_SELECT')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>{CIRCLE}</kbd>
|
||||
<kbd className={styles['kbd']}>{labels.right}</kbd>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_BACK')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>{TRIANGLE}</kbd>
|
||||
<kbd className={styles['kbd']}>{labels.top}</kbd>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_FULLSCREEN')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>{SQUARE}</kbd>
|
||||
<kbd className={styles['kbd']}>{labels.left}</kbd>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_GUIDE')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>{L1}</kbd>
|
||||
<kbd className={styles['kbd']}>{labels.lb}</kbd>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_PREV_TAB')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>{R1}</kbd>
|
||||
<kbd className={styles['kbd']}>{labels.rb}</kbd>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_NEXT_TAB')}</span>
|
||||
</div>
|
||||
|
|
@ -104,27 +128,27 @@ const GamepadModal = ({ onClose }: Props) => {
|
|||
<div className={styles['section']}>
|
||||
<div className={styles['section-title']}>{t('GAMEPAD_SECTION_PLAYER')}</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>{SQUARE}</kbd>
|
||||
<kbd className={styles['kbd']}>{labels.left}</kbd>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_PLAY_PAUSE')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>{R_STICK}</kbd>
|
||||
<kbd className={styles['kbd']}>{labels.rStick}</kbd>
|
||||
<span className={styles['dir']}>{LEFT}</span>
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_SEEK_BACK')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>{R_STICK}</kbd>
|
||||
<kbd className={styles['kbd']}>{labels.rStick}</kbd>
|
||||
<span className={styles['dir']}>{RIGHT}</span>
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_SEEK_FWD')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>{R_STICK}</kbd>
|
||||
<kbd className={styles['kbd']}>{labels.rStick}</kbd>
|
||||
<span className={styles['dir']}>{UP}</span>
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_VOL_UP')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>{R_STICK}</kbd>
|
||||
<kbd className={styles['kbd']}>{labels.rStick}</kbd>
|
||||
<span className={styles['dir']}>{DOWN}</span>
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_VOL_DOWN')}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={classnames(className, styles['main-nav-bars-container'])}>
|
||||
|
|
|
|||
|
|
@ -117,18 +117,18 @@ const ControlBar = React.forwardRef(({
|
|||
onSeekRequested={onSeekRequested}
|
||||
/>
|
||||
<div className={styles['control-bar-buttons-container']}>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': typeof paused !== 'boolean' })} title={paused ? t('PLAYER_PLAY') : t('PLAYER_PAUSE')} tabIndex={0} onClick={onPlayPauseButtonClick}>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': typeof paused !== 'boolean' })} title={paused ? t('PLAYER_PLAY') : t('PLAYER_PAUSE')} tabIndex={-1} onClick={onPlayPauseButtonClick}>
|
||||
<Icon className={styles['icon']} name={typeof paused !== 'boolean' || paused ? 'play' : 'pause'} />
|
||||
</Button>
|
||||
{
|
||||
nextVideo !== null ?
|
||||
<Button className={classnames(styles['control-bar-button'])} title={t('PLAYER_NEXT_VIDEO')} tabIndex={0} onClick={onNextVideoButtonClick}>
|
||||
<Button className={classnames(styles['control-bar-button'])} title={t('PLAYER_NEXT_VIDEO')} tabIndex={-1} onClick={onNextVideoButtonClick}>
|
||||
<Icon className={styles['icon']} name={'next'} />
|
||||
</Button>
|
||||
:
|
||||
null
|
||||
}
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': typeof muted !== 'boolean' })} title={muted ? t('PLAYER_UNMUTE') : t('PLAYER_MUTE')} tabIndex={0} onClick={onMuteButtonClick}>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': typeof muted !== 'boolean' })} title={muted ? t('PLAYER_UNMUTE') : t('PLAYER_MUTE')} tabIndex={-1} onClick={onMuteButtonClick}>
|
||||
<Icon
|
||||
className={styles['icon']}
|
||||
name={
|
||||
|
|
@ -156,33 +156,33 @@ const ControlBar = React.forwardRef(({
|
|||
<Icon className={styles['icon']} name={'more-vertical'} />
|
||||
</Button>
|
||||
<div className={classnames(styles['control-bar-buttons-menu-container'], { 'open': buttonsMenuOpen })}>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': statistics === null || statistics.type === 'Err' || stream === null || typeof stream.infoHash !== 'string' || typeof stream.fileIdx !== 'number' })} tabIndex={0} onMouseDown={onStatisticsButtonMouseDown} onClick={onToggleStatisticsMenu}>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': statistics === null || statistics.type === 'Err' || stream === null || typeof stream.infoHash !== 'string' || typeof stream.fileIdx !== 'number' })} tabIndex={-1} onMouseDown={onStatisticsButtonMouseDown} onClick={onToggleStatisticsMenu}>
|
||||
<Icon className={styles['icon']} name={'network'} />
|
||||
</Button>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': playbackSpeed === null })} tabIndex={0} onMouseDown={onSpeedButtonMouseDown} onClick={onToggleSpeedMenu}>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': playbackSpeed === null })} tabIndex={-1} onMouseDown={onSpeedButtonMouseDown} onClick={onToggleSpeedMenu}>
|
||||
<Icon className={styles['icon']} name={'speed'} />
|
||||
</Button>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': !chromecastServiceActive })} tabIndex={0} onClick={onChromecastButtonClick}>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': !chromecastServiceActive })} tabIndex={-1} onClick={onChromecastButtonClick}>
|
||||
<Icon className={styles['icon']} name={'cast'} />
|
||||
</Button>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': !Array.isArray(subtitlesTracks) || subtitlesTracks.length === 0 })} tabIndex={0} onMouseDown={onSubtitlesButtonMouseDown} onClick={onToggleSubtitlesMenu}>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': !Array.isArray(subtitlesTracks) || subtitlesTracks.length === 0 })} tabIndex={-1} onMouseDown={onSubtitlesButtonMouseDown} onClick={onToggleSubtitlesMenu}>
|
||||
<Icon className={styles['icon']} name={'subtitles'} />
|
||||
</Button>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': !Array.isArray(audioTracks) || audioTracks.length === 0 })} tabIndex={0} onMouseDown={onAudioButtonMouseDown} onClick={onToggleAudioMenu}>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': !Array.isArray(audioTracks) || audioTracks.length === 0 })} tabIndex={-1} onMouseDown={onAudioButtonMouseDown} onClick={onToggleAudioMenu}>
|
||||
<Icon className={styles['icon']} name={'audio-tracks'} />
|
||||
</Button>
|
||||
{
|
||||
metaItem?.content?.videos?.length > 0 ?
|
||||
<Button className={styles['control-bar-button']} tabIndex={0} onMouseDown={onVideosButtonMouseDown} onClick={onToggleSideDrawer}>
|
||||
<Button className={styles['control-bar-button']} tabIndex={-1} onMouseDown={onVideosButtonMouseDown} onClick={onToggleSideDrawer}>
|
||||
<Icon className={styles['icon']} name={'episodes'} />
|
||||
</Button>
|
||||
:
|
||||
null
|
||||
}
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': videoScale === null })} title={videoScaleLabel} tabIndex={0} onClick={onVideoScaleChanged}>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': videoScale === null })} title={videoScaleLabel} tabIndex={-1} onClick={onVideoScaleChanged}>
|
||||
<Icon className={styles['icon']} name={'scale'} />
|
||||
</Button>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': !stream })} tabIndex={0} onMouseDown={onOptionsButtonMouseDown} onClick={onToggleOptionsMenu}>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': !stream })} tabIndex={-1} onMouseDown={onOptionsButtonMouseDown} onClick={onToggleOptionsMenu}>
|
||||
<Icon className={styles['icon']} name={'more-horizontal'} />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ const SeekBar = ({ className, time, duration, buffered, onSeekRequested }) => {
|
|||
onSlide={onSlide}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
<Button onClick={toggleRemainingTimeMode} tabIndex={0}>
|
||||
<Button onClick={toggleRemainingTimeMode} tabIndex={-1}>
|
||||
<div className={styles['label']}>
|
||||
{remainingTimeMode && duration !== null && !isNaN(duration)
|
||||
? formatTime(duration - time, '-')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, Map<string, (data?: string) => 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<number>(0);
|
||||
const axisTimerRight = useRef<number>(0);
|
||||
const eventHandlers = useRef<GamepadEventHandlers>(new Map());
|
||||
const [controllerType, setControllerType] = useState<ControllerType>('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 (
|
||||
<GamepadContext.Provider value={{ on, off }}>
|
||||
<GamepadContext.Provider value={{ on, off, controllerType }}>
|
||||
{children}
|
||||
</GamepadContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
import GamepadProvider from './GamepadProvider';
|
||||
import useGamepad from './useGamepad';
|
||||
|
||||
export type { ControllerType } from './GamepadContext';
|
||||
|
||||
export {
|
||||
GamepadProvider,
|
||||
useGamepad
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useGamepad } from '../GamepadContext';
|
||||
|
||||
const FOCUSABLE = '[tabindex="0"]';
|
||||
const FOCUSABLE = '[tabindex]:not([data-focus-guard])';
|
||||
|
||||
const getActiveScope = (fallback: HTMLDivElement | null): HTMLElement | null => {
|
||||
const modal = document.querySelector<HTMLElement>('.modals-container');
|
||||
if (modal && modal.children.length > 0) return modal;
|
||||
if (document.querySelector('[data-gamepad-modal]')) return null;
|
||||
|
||||
const modals = document.querySelectorAll<HTMLElement>('.modals-container');
|
||||
for (const modal of modals) {
|
||||
if (modal.children.length > 0) return modal;
|
||||
}
|
||||
|
||||
const dropdown = fallback?.querySelector<HTMLElement>('[class*="dropdown"][class*="open"]');
|
||||
if (dropdown) return dropdown;
|
||||
|
|
|
|||
Loading…
Reference in a new issue