mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-05-10 07:11:48 +00:00
feat: add controller guide
This commit is contained in:
parent
d62ba32363
commit
f95273b8ce
14 changed files with 579 additions and 44 deletions
|
|
@ -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 = () => {
|
|||
<ToastProvider className={styles['toasts-container']}>
|
||||
<TooltipProvider className={styles['tooltip-container']}>
|
||||
<FileDropProvider className={styles['file-drop-container']}>
|
||||
<GamepadProvider enabled={gamepadSupportEnabled}>
|
||||
<GamepadProvider enabled={gamepadSupportEnabled} onGuide={toggleGamepadModal}>
|
||||
<ShortcutsProvider onShortcut={onShortcut}>
|
||||
{
|
||||
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
|
||||
}
|
||||
{
|
||||
gamepadModalOpen && <GamepadModal onClose={closeGamepadModal}/>
|
||||
}
|
||||
<ServicesToaster />
|
||||
<DeepLinkHandler />
|
||||
<SearchParamsHandler />
|
||||
|
|
|
|||
224
src/App/GamepadModal/GamepadDiagram.tsx
Normal file
224
src/App/GamepadModal/GamepadDiagram.tsx
Normal file
|
|
@ -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<ActiveButton>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
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 (
|
||||
<svg className={styles['diagram']} viewBox={'0 0 800 510'} xmlns={'http://www.w3.org/2000/svg'}>
|
||||
<defs>
|
||||
<linearGradient id={'bodyGrad'} x1={'0'} y1={'0'} x2={'0'} y2={'1'}>
|
||||
<stop offset={'0%'} stopColor={'#2a2545'} />
|
||||
<stop offset={'100%'} stopColor={'#1a1530'} />
|
||||
</linearGradient>
|
||||
<linearGradient id={'triggerGrad'} x1={'0'} y1={'0'} x2={'0'} y2={'1'}>
|
||||
<stop offset={'0%'} stopColor={'#1e1a35'} />
|
||||
<stop offset={'100%'} stopColor={'#16122a'} />
|
||||
</linearGradient>
|
||||
<linearGradient id={'bumperGrad'} x1={'0'} y1={'0'} x2={'0'} y2={'1'}>
|
||||
<stop offset={'0%'} stopColor={'#3d3660'} />
|
||||
<stop offset={'100%'} stopColor={'#2a2545'} />
|
||||
</linearGradient>
|
||||
<filter id={'glow'} x={'-50%'} y={'-50%'} width={'200%'} height={'200%'}>
|
||||
<feGaussianBlur stdDeviation={'4'} result={'blur'} />
|
||||
<feMerge>
|
||||
<feMergeNode in={'blur'} />
|
||||
<feMergeNode in={'SourceGraphic'} />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* ===== L2 / R2 TRIGGERS (drawn first, behind everything) ===== */}
|
||||
<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'}>L2</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'}>R2</text>
|
||||
|
||||
{/* ===== CONTROLLER BODY ===== */}
|
||||
<path
|
||||
d={`M${CX - 178},${105 + BY}
|
||||
Q${CX - 165},${80 + BY} ${CX - 95},${74 + BY}
|
||||
L${CX + 95},${74 + BY}
|
||||
Q${CX + 165},${80 + BY} ${CX + 178},${105 + BY}
|
||||
L${CX + 195},${135 + BY}
|
||||
Q${CX + 232},${172 + BY} ${CX + 252},${232 + BY}
|
||||
Q${CX + 272},${298 + BY} ${CX + 255},${350 + BY}
|
||||
Q${CX + 238},${390 + BY} ${CX + 203},${400 + BY}
|
||||
Q${CX + 168},${410 + BY} ${CX + 150},${382 + BY}
|
||||
L${CX + 113},${320 + BY}
|
||||
Q${CX + 90},${284 + BY} ${CX},${284 + BY}
|
||||
Q${CX - 90},${284 + BY} ${CX - 113},${320 + BY}
|
||||
L${CX - 150},${382 + BY}
|
||||
Q${CX - 168},${410 + BY} ${CX - 203},${400 + BY}
|
||||
Q${CX - 238},${390 + BY} ${CX - 255},${350 + BY}
|
||||
Q${CX - 272},${298 + BY} ${CX - 252},${232 + BY}
|
||||
Q${CX - 232},${172 + BY} ${CX - 195},${135 + BY}
|
||||
Z`}
|
||||
fill={'url(#bodyGrad)'}
|
||||
stroke={'#3d3660'}
|
||||
strokeWidth={'2.5'}
|
||||
/>
|
||||
|
||||
{/* ===== TOUCHPAD ===== */}
|
||||
<rect x={CX - 58} y={96 + BY} rx={'8'} ry={'8'} width={'116'} height={'48'} fill={'#1e1a35'} stroke={'#3d3660'} strokeWidth={'1.5'} />
|
||||
|
||||
{/* ===== L1 / R1 BUMPERS (on top of body, on shoulder edge) ===== */}
|
||||
<g filter={active === 'l1' ? '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}
|
||||
/>
|
||||
<text x={CX - SX} y={'78'} textAnchor={'middle'} fill={'#a89ecc'} fontSize={'9'} fontWeight={'600'}>L1</text>
|
||||
</g>
|
||||
<g filter={active === 'r1' ? '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}
|
||||
/>
|
||||
<text x={CX + SX} y={'78'} textAnchor={'middle'} fill={'#a89ecc'} fontSize={'9'} fontWeight={'600'}>R1</text>
|
||||
</g>
|
||||
|
||||
{/* ===== FACE BUTTONS (right, centered at CX+BX) ===== */}
|
||||
{/* △ */}
|
||||
<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>
|
||||
{/* ○ */}
|
||||
<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>
|
||||
{/* ✕ */}
|
||||
<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>
|
||||
{/* □ */}
|
||||
<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>
|
||||
|
||||
{/* ===== D-PAD (left, mirrored at CX-BX) — cosmetic only ===== */}
|
||||
<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'} />
|
||||
|
||||
{/* ===== LEFT STICK (CX-STX) ===== */}
|
||||
<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>
|
||||
</g>
|
||||
|
||||
{/* ===== RIGHT STICK (CX+STX) ===== */}
|
||||
<circle cx={CX + STX} cy={240 + BY} r={'26'} fill={'#1a1530'} stroke={'#3d3660'} strokeWidth={'1.5'} />
|
||||
<circle cx={CX + STX} cy={240 + BY} r={'17'} fill={'#1e1a35'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.35'} />
|
||||
|
||||
{/* ============================= */}
|
||||
{/* ===== LABELS — LEFT ===== */}
|
||||
{/* ============================= */}
|
||||
|
||||
{/* L1 */}
|
||||
<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'} />
|
||||
<text x={'80'} y={'44'} textAnchor={'end'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_PREV_TAB')}</text>
|
||||
|
||||
{/* Left stick */}
|
||||
<line x1={CX - STX - 24} y1={232 + BY} x2={'85'} y2={168} stroke={'#7b5bf5'} strokeWidth={'1'} opacity={'0.4'} />
|
||||
<circle cx={'85'} cy={168} r={'2'} fill={'#7b5bf5'} />
|
||||
<text x={'80'} y={164} textAnchor={'end'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_NAVIGATE')}</text>
|
||||
<text x={'80'} y={179} textAnchor={'end'} fill={'#8b7faa'} fontSize={'10'}>{t('GAMEPAD_LABEL_STICK_PLAYER')}</text>
|
||||
|
||||
{/* □ Square */}
|
||||
<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'} />
|
||||
<text x={'80'} y={244} textAnchor={'end'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_GUIDE')}</text>
|
||||
|
||||
{/* ============================= */}
|
||||
{/* ===== LABELS — RIGHT ===== */}
|
||||
{/* ============================= */}
|
||||
|
||||
{/* R1 */}
|
||||
<line x1={CX + SX + 40} y1={'74'} x2={'715'} y2={'48'} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
|
||||
<circle cx={'715'} cy={'48'} r={'2'} fill={'#5848a0'} />
|
||||
<text x={'720'} y={'44'} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_NEXT_TAB')}</text>
|
||||
|
||||
{/* △ Triangle */}
|
||||
<line x1={CX + BX + 13} y1={112 + BY} x2={'715'} y2={108} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
|
||||
<circle cx={'715'} cy={108} r={'2'} fill={'#5848a0'} />
|
||||
<text x={'720'} y={104} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_FULLSCREEN')}</text>
|
||||
|
||||
{/* ○ Circle */}
|
||||
<line x1={CX + BX + 43} y1={142 + BY} x2={'715'} y2={148} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
|
||||
<circle cx={'715'} cy={148} r={'2'} fill={'#5848a0'} />
|
||||
<text x={'720'} y={144} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_BACK')}</text>
|
||||
|
||||
{/* ✕ Cross */}
|
||||
<line x1={CX + BX + 13} y1={184 + BY} x2={'715'} y2={208} stroke={'#7b5bf5'} strokeWidth={'1'} opacity={'0.4'} />
|
||||
<circle cx={'715'} cy={208} r={'2'} fill={'#7b5bf5'} />
|
||||
<text x={'720'} y={204} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_SELECT')}</text>
|
||||
<text x={'720'} y={219} textAnchor={'start'} fill={'#8b7faa'} fontSize={'10'}>{t('GAMEPAD_LABEL_PLAY_PAUSE_PLAYER')}</text>
|
||||
|
||||
{/* Compat note */}
|
||||
<text x={CX} y={'475'} textAnchor={'middle'} fill={'#5848a0'} fontSize={'11'}>{t('GAMEPAD_LABEL_COMPAT')}</text>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default GamepadDiagram;
|
||||
126
src/App/GamepadModal/GamepadModal.tsx
Normal file
126
src/App/GamepadModal/GamepadModal.tsx
Normal file
|
|
@ -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((
|
||||
<div className={styles['gamepad-modal']}>
|
||||
<div className={styles['backdrop']} onClick={onClose} />
|
||||
|
||||
<div className={styles['container']}>
|
||||
<div className={styles['header']}>
|
||||
<div className={styles['title']}>
|
||||
{t('GAMEPAD_CONTROLS_TITLE')}
|
||||
</div>
|
||||
|
||||
<Button className={styles['close-button']} title={t('BUTTON_CLOSE')} onClick={onClose}>
|
||||
<Icon className={styles['icon']} name={'close'} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles['content']}>
|
||||
<GamepadDiagram />
|
||||
|
||||
<div className={styles['sections']}>
|
||||
<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>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_NAVIGATE')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>✕</kbd>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_SELECT')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>○</kbd>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_BACK')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>△</kbd>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_FULLSCREEN')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>□</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>
|
||||
<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>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_NEXT_TAB')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles['section']}>
|
||||
<div className={styles['section-title']}>{t('GAMEPAD_SECTION_PLAYER')}</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>✕</kbd>
|
||||
<span className={styles['dir']} />
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_PLAY_PAUSE')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>L stick</kbd>
|
||||
<span className={styles['dir']}>{'←'}</span>
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_SEEK_BACK')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>L stick</kbd>
|
||||
<span className={styles['dir']}>{'→'}</span>
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_SEEK_FWD')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>L stick</kbd>
|
||||
<span className={styles['dir']}>{'↑'}</span>
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_VOL_UP')}</span>
|
||||
</div>
|
||||
<div className={styles['mapping']}>
|
||||
<kbd className={styles['kbd']}>L stick</kbd>
|
||||
<span className={styles['dir']}>{'↓'}</span>
|
||||
<span className={styles['action']}>{t('GAMEPAD_ACTION_VOL_DOWN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body);
|
||||
};
|
||||
|
||||
export default GamepadModal;
|
||||
4
src/App/GamepadModal/index.ts
Normal file
4
src/App/GamepadModal/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Copyright (C) 2017-2026 Smart code 203358507
|
||||
|
||||
import GamepadModal from './GamepadModal';
|
||||
export default GamepadModal;
|
||||
174
src/App/GamepadModal/styles.less
Normal file
174
src/App/GamepadModal/styles.less
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,11 @@
|
|||
"name": "shortcuts",
|
||||
"label": "SETTINGS_SHORTCUT_SHORTCUTS",
|
||||
"combos": [["Ctrl", "/"]]
|
||||
},
|
||||
{
|
||||
"name": "gamepadGuide",
|
||||
"label": "GAMEPAD_ACTION_GUIDE",
|
||||
"combos": [["Ctrl", "G"]]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
// Copyright (C) 2017-2026 Smart code 203358507
|
||||
|
||||
import { createContext } from 'react';
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, Map<string, (data?: any) => 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<number>(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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (C) 2017-2026 Smart code 203358507
|
||||
|
||||
import GamepadProvider from './GamepadProvider';
|
||||
import useGamepad from './useGamepad';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (C) 2017-2026 Smart code 203358507
|
||||
|
||||
import useContentGamepadNavigation from './useContentGamepadNavigation';
|
||||
import useVerticalNavGamepadNavigation from './useVerticalNavGamepadNavigation';
|
||||
import useHorizontalNavGamepadNavigation from './useHorizontalNavGamepadNavigation';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>, gamepadHandlerId: string) => {
|
||||
const ROUTES = ['board', 'discover', 'library', 'calendar', 'addons', 'settings'];
|
||||
|
||||
const useVerticalGamepadNavigation = (_sectionRef: React.RefObject<HTMLDivElement>, 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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue