Merge pull request #882 from Stremio/feat/gamepad-support

Feat: Gamepad Support in Web v5
This commit is contained in:
Timothy Z. 2026-04-29 14:06:23 +03:00 committed by GitHub
commit 2075b1c521
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1237 additions and 45 deletions

View file

@ -4,7 +4,7 @@ require('spatial-navigation-polyfill');
const React = require('react');
const { useTranslation } = require('react-i18next');
const { Router } = require('stremio-router');
const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services');
const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider, GamepadProvider } = require('stremio/services');
const { NotFound } = require('stremio/routes');
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, ShortcutsProvider, CONSTANTS, withCoreSuspender, useShell, useBinaryState } = require('stremio/common');
const ServicesToaster = require('./ServicesToaster');
@ -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');
@ -22,6 +23,7 @@ const RouterWithProtectedRoutes = withCoreSuspender(withProtectedRoutes(Router))
const App = () => {
const { i18n } = useTranslation();
const shell = useShell();
const [gamepadSupportEnabled, setGamepadSupportEnabled] = React.useState(false);
const onPathNotMatch = React.useCallback(() => {
return NotFound;
}, []);
@ -40,12 +42,18 @@ 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();
switch (name) {
case 'shortcuts':
toggleShortcutModal();
break;
case 'gamepadGuide':
toggleGamepadModal();
break;
}
}, [toggleShortcutModal]);
}, [toggleShortcutModal, toggleGamepadModal]);
React.useEffect(() => {
let prevPath = window.location.hash.slice(1);
@ -141,6 +149,10 @@ const App = () => {
i18n.changeLanguage(args.settings.interfaceLanguage);
}
if (args?.settings?.gamepadSupport !== undefined) {
setGamepadSupportEnabled(args.settings.gamepadSupport);
}
if (args?.settings?.quitOnClose && shell.windowClosed) {
shell.send('quit');
}
@ -154,6 +166,10 @@ const App = () => {
i18n.changeLanguage(state.profile.settings.interfaceLanguage);
}
if (typeof state.profile.settings.gamepadSupport === 'boolean') {
setGamepadSupportEnabled(state.profile.settings.gamepadSupport);
}
if (state?.profile?.settings?.quitOnClose && shell.windowClosed) {
shell.send('quit');
}
@ -213,20 +229,25 @@ const App = () => {
<ToastProvider className={styles['toasts-container']}>
<TooltipProvider className={styles['tooltip-container']}>
<FileDropProvider className={styles['file-drop-container']}>
<ShortcutsProvider onShortcut={onShortcut}>
{
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
}
<ServicesToaster />
<DeepLinkHandler />
<SearchParamsHandler />
<UpdaterBanner className={styles['updater-banner-container']} />
<RouterWithProtectedRoutes
className={styles['router']}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
</ShortcutsProvider>
<GamepadProvider enabled={gamepadSupportEnabled} onGuide={toggleGamepadModal}>
<ShortcutsProvider onShortcut={onShortcut}>
{
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
}
{
gamepadModalOpen && <GamepadModal onClose={closeGamepadModal}/>
}
<ServicesToaster />
<DeepLinkHandler />
<SearchParamsHandler />
<UpdaterBanner className={styles['updater-banner-container']} />
<RouterWithProtectedRoutes
className={styles['router']}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
</ShortcutsProvider>
</GamepadProvider>
</FileDropProvider>
</TooltipProvider>
</ToastProvider>

View file

@ -0,0 +1,214 @@
// 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 BTN = { L1: 'L1', L2: 'L2', R1: 'R1', R2: 'R2' };
const ARROW = { UP: '↑', DOWN: '↓', LEFT: '←', RIGHT: '→' };
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) => dir && flash('stick-' + dir)());
gamepad?.on('analogRight', 'gamepad-diagram', (dir) => dir && flash('rstick-' + 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?.off('analogRight', '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>
<g className={styles['anim-controls']}>
<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.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'}>{BTN.R2}</text>
</g>
<path
className={styles['anim-body']}
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'}
/>
<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}>
<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'}>{BTN.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'}>{BTN.R1}</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>
<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>
<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'} />
<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>
<g filter={active?.startsWith('rstick-') ? 'url(#glow)' : undefined}>
<circle cx={CX + STX} cy={240 + BY} r={'26'} fill={'#1a1530'} stroke={active?.startsWith('rstick-') ? '#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 === 'rstick-up' ? '#fff' : '#5848a0'} fontSize={'9'} fontWeight={active === 'rstick-up' ? '700' : '400'}>{ARROW.UP}</text>
<text x={CX + STX} y={253 + BY} textAnchor={'middle'} fill={active === 'rstick-down' ? '#fff' : '#5848a0'} fontSize={'9'} fontWeight={active === 'rstick-down' ? '700' : '400'}>{ARROW.DOWN}</text>
<text x={CX + STX - 11} y={244 + BY} textAnchor={'middle'} fill={active === 'rstick-left' ? '#fff' : '#5848a0'} fontSize={'9'} fontWeight={active === 'rstick-left' ? '700' : '400'}>{ARROW.LEFT}</text>
<text x={CX + STX + 11} y={244 + BY} textAnchor={'middle'} fill={active === 'rstick-right' ? '#fff' : '#5848a0'} fontSize={'9'} fontWeight={active === 'rstick-right' ? '700' : '400'}>{ARROW.RIGHT}</text>
</g>
</g>
<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'} />
<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'} />
<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'} />
<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'} />
<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'} />
<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'} />
<line x1={CX + STX + 24} y1={234 + BY} x2={'715'} y2={268} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'715'} cy={268} r={'2'} fill={'#5848a0'} />
</g>
<g className={styles['anim-labels']}>
<text x={'80'} y={'44'} textAnchor={'end'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_PREV_TAB')}</text>
<text x={'80'} y={164} textAnchor={'end'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_NAVIGATE')}</text>
<text x={'80'} y={244} textAnchor={'end'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_GUIDE')}</text>
<text x={'80'} y={259} textAnchor={'end'} fill={'#8b7faa'} fontSize={'10'}>{t('GAMEPAD_LABEL_PLAY_PAUSE_PLAYER')}</text>
<text x={'720'} y={'44'} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_NEXT_TAB')}</text>
<text x={'720'} y={104} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_FULLSCREEN')}</text>
<text x={'720'} y={144} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_BACK')}</text>
<text x={'720'} y={204} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_SELECT')}</text>
<text x={'720'} y={264} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_LABEL_SEEK_VOL')}</text>
<text x={CX} y={'475'} textAnchor={'middle'} fill={'#5848a0'} fontSize={'11'}>{t('GAMEPAD_LABEL_COMPAT')}</text>
</g>
</svg>
);
};
export default GamepadDiagram;

View file

@ -0,0 +1,139 @@
// 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';
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 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']}>{CROSS}</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>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_BACK')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{TRIANGLE}</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>
<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']}>{SQUARE}</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>
<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>
<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>
<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>
<span className={styles['dir']}>{DOWN}</span>
<span className={styles['action']}>{t('GAMEPAD_ACTION_VOL_DOWN')}</span>
</div>
</div>
</div>
</div>
</div>
</div>
), document.body);
};
export default GamepadModal;

View file

@ -0,0 +1,4 @@
// Copyright (C) 2017-2026 Smart code 203358507
import GamepadModal from './GamepadModal';
export default GamepadModal;

View file

@ -0,0 +1,214 @@
// 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;
}
}
@keyframes draw-stroke {
from { stroke-dashoffset: 2000; }
to { stroke-dashoffset: 0; }
}
@keyframes draw-line {
from { stroke-dashoffset: 800; }
to { stroke-dashoffset: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.diagram {
width: 100%;
max-width: 48rem;
height: auto;
.anim-body {
stroke-dasharray: 2000;
stroke-dashoffset: 0;
animation: draw-stroke 1.4s ease-in-out;
}
.anim-controls {
animation: fade-in 0.6s ease-out 1s both;
}
.anim-lines {
line {
stroke-dasharray: 800;
stroke-dashoffset: 0;
animation: draw-line 0.8s ease-out 1.6s both;
}
circle {
animation: fade-in 0.3s ease-out 2s both;
}
}
.anim-labels {
animation: fade-in 0.5s ease-out 2.2s both;
}
}
.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;
}
}
}

View file

@ -27,6 +27,11 @@
"name": "shortcuts",
"label": "SETTINGS_SHORTCUT_SHORTCUTS",
"combos": [["Ctrl", "/"]]
},
{
"name": "gamepadGuide",
"label": "GAMEPAD_ACTION_GUIDE",
"combos": [["Ctrl", "G"]]
}
]
},

View file

@ -27,18 +27,27 @@
width: @width;
padding: 0 1rem;
cursor: pointer;
border-radius: 2rem;
outline: none;
.icon {
width: calc(@width / 2);
height: calc(@height / 2);
color: var(--primary-foreground-color);
opacity: 0.7;
}
&:hover {
&:hover, &:focus {
.icon {
opacity: 1;
}
}
&:focus-visible {
outline: 2px solid var(--primary-foreground-color);
outline-offset: -2px;
}
&.disabled {
pointer-events: none;
}

View file

@ -28,6 +28,7 @@ const ActionsGroup = ({ items, className }: Props) => {
<div
key={index}
className={classNames(styles['icon-container'], item.className, { [styles['disabled']]: item.disabled })}
tabIndex={0}
onClick={item.onClick}
>
{

View file

@ -33,7 +33,7 @@ const Chip = memo(({ label, value, active, onSelect }: Props) => {
ref={ref}
key={value}
className={classNames(styles['chip'], { [styles['active']]: active })}
tabIndex={-1}
tabIndex={0}
data-value={value}
onClick={onClick}
>

View file

@ -3,6 +3,7 @@
import React, { memo } from 'react';
import classnames from 'classnames';
import { VerticalNavBar, HorizontalNavBar } from 'stremio/components/NavBar';
import { useContentGamepadNavigation, useVerticalNavGamepadNavigation } from 'stremio/services/GamepadNavigation';
import styles from './MainNavBars.less';
const TABS = [
@ -22,6 +23,12 @@ type Props = {
};
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');
return (
<div className={classnames(className, styles['main-nav-bars-container'])}>
<HorizontalNavBar
@ -34,11 +41,12 @@ const MainNavBars = memo(({ className, route, query, children }: Props) => {
navMenu={true}
/>
<VerticalNavBar
ref={navRef}
className={styles['vertical-nav-bar']}
selected={route}
tabs={TABS}
/>
<div className={styles['nav-content-container']}>{children}</div>
<div ref={contentRef} className={styles['nav-content-container']}>{children}</div>
</div>
);
});

View file

@ -159,7 +159,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
title={linksGroups.get(CONSTANTS.IMDB_LINK_CATEGORY).label}
href={linksGroups.get(CONSTANTS.IMDB_LINK_CATEGORY).href}
target={'_blank'}
{...(compact ? { tabIndex: -1 } : null)}
tabIndex={0}
>
<div className={styles['label']}>{linksGroups.get(CONSTANTS.IMDB_LINK_CATEGORY).label}</div>
<Icon className={styles['icon']} name={'imdb'} />
@ -214,7 +214,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
className={styles['action-button']}
icon={'trailer'}
label={t('TRAILER')}
tabIndex={compact ? -1 : 0}
tabIndex={0}
href={trailerHref}
tooltip={compact}
/>
@ -232,7 +232,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
className={classnames(styles['action-button'], styles['show-button'])}
icon={'play'}
label={t('SHOW')}
tabIndex={compact ? -1 : 0}
tabIndex={0}
href={showHref}
/>
:
@ -255,7 +255,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
icon={'share'}
label={t('CTX_SHARE')}
tooltip={true}
tabIndex={compact ? -1 : 0}
tabIndex={0}
onClick={openShareModal}
/>
{

View file

@ -7,6 +7,7 @@ const { default: Icon } = require('@stremio/stremio-icons/react');
const { Button, Image } = require('stremio/components');
const { default: useFullscreen } = require('stremio/common/useFullscreen');
const usePWA = require('stremio/common/usePWA');
const { useHorizontalNavGamepadNavigation } = require('stremio/services/GamepadNavigation');
const SearchBar = require('./SearchBar');
const NavMenu = require('./NavMenu');
const styles = require('./styles');
@ -24,6 +25,7 @@ const HorizontalNavBar = React.memo(({ className, route, query, title, backButto
{children}
</Button>
), []);
useHorizontalNavGamepadNavigation(route || className, backButton);
return (
<nav {...props} className={classnames(className, styles['horizontal-nav-bar-container'])}>
{

View file

@ -7,10 +7,10 @@ const { useTranslation } = require('react-i18next');
const NavTabButton = require('./NavTabButton');
const styles = require('./styles');
const VerticalNavBar = React.memo(({ className, selected, tabs }) => {
const VerticalNavBar = React.memo(React.forwardRef(({ className, selected, tabs }, ref) => {
const { t } = useTranslation();
return (
<nav className={classnames(className, styles['vertical-nav-bar-container'])}>
<nav ref={ref} className={classnames(className, styles['vertical-nav-bar-container'])}>
{
Array.isArray(tabs) ?
tabs.map((tab, index) => (
@ -30,7 +30,7 @@ const VerticalNavBar = React.memo(({ className, selected, tabs }) => {
}
</nav>
);
});
}));
VerticalNavBar.displayName = 'VerticalNavBar';

View file

@ -5,6 +5,7 @@ const { useTranslation } = require('react-i18next');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { useServices } = require('stremio/services');
const { useContentGamepadNavigation } = require('stremio/services/GamepadNavigation');
const { withCoreSuspender } = require('stremio/common');
const { VerticalNavBar, HorizontalNavBar, DelayedRenderer, Image, MetaPreview, ModalDialog } = require('stremio/components');
const StreamsList = require('./StreamsList');
@ -15,6 +16,7 @@ const useMetaExtensionTabs = require('./useMetaExtensionTabs');
const styles = require('./styles');
const MetaDetails = ({ urlParams, queryParams }) => {
const contentRef = React.useRef(null);
const { t } = useTranslation();
const { core } = useServices();
const metaDetails = useMetaDetails(urlParams);
@ -111,6 +113,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
metaDetails.metaItem.content.content.background.length > 0
), [metaPath, metaDetails]);
useContentGamepadNavigation(contentRef, urlParams.path);
return (
<div className={styles['metadetails-container']}>
{
@ -132,7 +135,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
fullscreenButton={true}
navMenu={true}
/>
<div className={styles['metadetails-content']}>
<div ref={contentRef} className={styles['metadetails-content']}>
{
tabs.length > 0 ?
<VerticalNavBar
@ -238,6 +241,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
MetaDetails.propTypes = {
urlParams: PropTypes.shape({
path: PropTypes.string,
type: PropTypes.string,
id: PropTypes.string,
videoId: PropTypes.string

View file

@ -104,7 +104,7 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
{
video ?
<React.Fragment>
<Button className={classnames(styles['button-container'], styles['back-button-container'])} tabIndex={-1} onClick={backButtonOnClick}>
<Button className={classnames(styles['button-container'], styles['back-button-container'])} tabIndex={0} onClick={backButtonOnClick}>
<Icon className={styles['icon']} name={'chevron-back'} />
</Button>
<div className={styles['episode-title']}>

View file

@ -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={-1} onClick={onPlayPauseButtonClick}>
<Button className={classnames(styles['control-bar-button'], { 'disabled': typeof paused !== 'boolean' })} title={paused ? t('PLAYER_PLAY') : t('PLAYER_PAUSE')} tabIndex={0} 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={-1} onClick={onNextVideoButtonClick}>
<Button className={classnames(styles['control-bar-button'])} title={t('PLAYER_NEXT_VIDEO')} tabIndex={0} 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={-1} onClick={onMuteButtonClick}>
<Button className={classnames(styles['control-bar-button'], { 'disabled': typeof muted !== 'boolean' })} title={muted ? t('PLAYER_UNMUTE') : t('PLAYER_MUTE')} tabIndex={0} 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={-1} 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={0} onMouseDown={onStatisticsButtonMouseDown} onClick={onToggleStatisticsMenu}>
<Icon className={styles['icon']} name={'network'} />
</Button>
<Button className={classnames(styles['control-bar-button'], { 'disabled': playbackSpeed === null })} tabIndex={-1} onMouseDown={onSpeedButtonMouseDown} onClick={onToggleSpeedMenu}>
<Button className={classnames(styles['control-bar-button'], { 'disabled': playbackSpeed === null })} tabIndex={0} onMouseDown={onSpeedButtonMouseDown} onClick={onToggleSpeedMenu}>
<Icon className={styles['icon']} name={'speed'} />
</Button>
<Button className={classnames(styles['control-bar-button'], { 'disabled': !chromecastServiceActive })} tabIndex={-1} onClick={onChromecastButtonClick}>
<Button className={classnames(styles['control-bar-button'], { 'disabled': !chromecastServiceActive })} tabIndex={0} onClick={onChromecastButtonClick}>
<Icon className={styles['icon']} name={'cast'} />
</Button>
<Button className={classnames(styles['control-bar-button'], { 'disabled': !Array.isArray(subtitlesTracks) || subtitlesTracks.length === 0 })} tabIndex={-1} onMouseDown={onSubtitlesButtonMouseDown} onClick={onToggleSubtitlesMenu}>
<Button className={classnames(styles['control-bar-button'], { 'disabled': !Array.isArray(subtitlesTracks) || subtitlesTracks.length === 0 })} tabIndex={0} 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={-1} onMouseDown={onAudioButtonMouseDown} onClick={onToggleAudioMenu}>
<Button className={classnames(styles['control-bar-button'], { 'disabled': !Array.isArray(audioTracks) || audioTracks.length === 0 })} tabIndex={0} onMouseDown={onAudioButtonMouseDown} onClick={onToggleAudioMenu}>
<Icon className={styles['icon']} name={'audio-tracks'} />
</Button>
{
metaItem?.content?.videos?.length > 0 ?
<Button className={styles['control-bar-button']} tabIndex={-1} onMouseDown={onVideosButtonMouseDown} onClick={onToggleSideDrawer}>
<Button className={styles['control-bar-button']} tabIndex={0} 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={-1} onClick={onVideoScaleChanged}>
<Button className={classnames(styles['control-bar-button'], { 'disabled': videoScale === null })} title={videoScaleLabel} tabIndex={0} onClick={onVideoScaleChanged}>
<Icon className={styles['icon']} name={'scale'} />
</Button>
<Button className={classnames(styles['control-bar-button'], { 'disabled': !stream })} tabIndex={-1} onMouseDown={onOptionsButtonMouseDown} onClick={onToggleOptionsMenu}>
<Button className={classnames(styles['control-bar-button'], { 'disabled': !stream })} tabIndex={0} onMouseDown={onOptionsButtonMouseDown} onClick={onToggleOptionsMenu}>
<Icon className={styles['icon']} name={'more-horizontal'} />
</Button>
</div>

View file

@ -59,7 +59,7 @@ const SeekBar = ({ className, time, duration, buffered, onSeekRequested }) => {
onSlide={onSlide}
onComplete={onComplete}
/>
<Button onClick={toggleRemainingTimeMode} tabIndex={-1}>
<Button onClick={toggleRemainingTimeMode} tabIndex={0}>
<div className={styles['label']}>
{remainingTimeMode && duration !== null && !isNaN(duration)
? formatTime(duration - time, '-')

View file

@ -7,7 +7,8 @@ const debounce = require('lodash.debounce');
const langs = require('langs');
const { useTranslation } = require('react-i18next');
const { useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
const { useServices, useGamepad } = require('stremio/services');
const { useContentGamepadNavigation } = require('stremio/services/GamepadNavigation');
const { onFileDrop, useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell, usePlatform, onShortcut } = require('stremio/common');
const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components');
const BufferingLoader = require('./BufferingLoader');
@ -33,10 +34,13 @@ const { default: useMediaSession } = require('./useMediaSession');
const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang);
const findTrackById = (tracks, id) => tracks.find((track) => track.id === id);
const GAMEPAD_HANDLER_ID = 'player';
const Player = ({ urlParams, queryParams }) => {
const { t } = useTranslation();
const services = useServices();
const shell = useShell();
const gamepad = useGamepad();
const forceTranscoding = React.useMemo(() => {
return queryParams.has('forceTranscoding');
}, [queryParams]);
@ -57,6 +61,7 @@ const Player = ({ urlParams, queryParams }) => {
});
const playbackDevices = React.useMemo(() => streamingServer.playbackDevices !== null && streamingServer.playbackDevices.type === 'Ready' ? streamingServer.playbackDevices.content : [], [streamingServer]);
const playerRef = React.useRef(null);
const bufferingRef = React.useRef();
const errorRef = React.useRef();
@ -380,6 +385,79 @@ const Player = ({ urlParams, queryParams }) => {
video.addLocalSubtitles(filename, buffer);
});
const onPlayPause = React.useCallback(() => {
if (!menusOpen && !nextVideoPopupOpen && video.state.paused !== null) {
if (video.state.paused) {
onPlayRequested();
setSeeking(false);
} else {
onPauseRequested();
}
}
}, [menusOpen, nextVideoPopupOpen, video.state.paused]);
const onSeekPrev = React.useCallback((event) => {
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
const seekDuration = event?.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration;
const seekTime = video.state.time - seekDuration;
setSeeking(true);
onSeekRequested(Math.max(seekTime, 0));
}
}, [menusOpen, nextVideoPopupOpen, video.state.time]);
const onSeekNext = React.useCallback((event) => {
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
const seekDuration = event?.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration;
setSeeking(true);
onSeekRequested(video.state.time + seekDuration);
}
}, [menusOpen, nextVideoPopupOpen, video.state.time]);
const onVolumeUp = React.useCallback(() => {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
onVolumeChangeRequested(Math.min(video.state.volume + 5, 200));
}
}, [menusOpen, nextVideoPopupOpen, video.state.volume]);
const onVolumeDown = React.useCallback(() => {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
onVolumeChangeRequested(Math.max(video.state.volume - 5, 0));
}
}, [menusOpen, nextVideoPopupOpen, video.state.volume]);
const onGamepadSeekAndVol = React.useCallback((axis) => {
switch(axis) {
case 'left': {
onSeekPrev();
break;
}
case 'right': {
onSeekNext();
break;
}
case 'up': {
onVolumeUp();
break;
}
case 'down': {
onVolumeDown();
break;
}
}
}, [onSeekPrev, onSeekNext, onVolumeUp, onVolumeDown]);
useContentGamepadNavigation(playerRef, GAMEPAD_HANDLER_ID);
React.useEffect(() => {
gamepad?.on('buttonX', GAMEPAD_HANDLER_ID, onPlayPause);
gamepad?.on('analogRight', GAMEPAD_HANDLER_ID, onGamepadSeekAndVol);
return () => {
gamepad?.off('buttonX', GAMEPAD_HANDLER_ID);
gamepad?.off('analogRight', GAMEPAD_HANDLER_ID);
};
}, [onPlayPause, onGamepadSeekAndVol]);
React.useEffect(() => {
setError(null);
video.unload();
@ -895,7 +973,7 @@ const Player = ({ urlParams, queryParams }) => {
}, []);
return (
<div className={classnames(styles['player-container'], { [styles['overlayHidden']]: overlayHidden })}
<div ref={playerRef} className={classnames(styles['player-container'], { [styles['overlayHidden']]: overlayHidden })}
onMouseDown={onContainerMouseDown}
onMouseMove={onContainerMouseMove}
onMouseOver={onContainerMouseMove}

View file

@ -16,6 +16,7 @@ const Interface = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) =>
quitOnCloseToggle,
escExitFullscreenToggle,
hideSpoilersToggle,
gamepadSupportToggle,
} = useInterfaceOptions(profile);
return (
@ -50,6 +51,12 @@ const Interface = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) =>
{...hideSpoilersToggle}
/>
</Option>
<Option label={'SETTINGS_GAMEPAD'}>
<Toggle
tabIndex={-1}
{...gamepadSupportToggle}
/>
</Option>
</Section>
);
});

View file

@ -81,11 +81,28 @@ const useInterfaceOptions = (profile: Profile) => {
}
}), [profile.settings]);
const gamepadSupportToggle = useMemo(() => ({
checked: profile.settings.gamepadSupport,
onClick: () => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
gamepadSupport: !profile.settings.gamepadSupport
}
}
});
}
}), [profile.settings]);
return {
interfaceLanguageSelect,
escExitFullscreenToggle,
quitOnCloseToggle,
hideSpoilersToggle,
gamepadSupportToggle,
};
};

View file

@ -0,0 +1,10 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { createContext } from 'react';
const GamepadContext = createContext<{
on: (event: string, id: string, callback: (data?: string) => void) => void;
off: (event: string, id: string) => void;
} | null>(null);
export default GamepadContext;

View file

@ -0,0 +1,226 @@
// Copyright (C) 2017-2026 Smart code 203358507
import React, { useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import useToast from 'stremio/common/Toast/useToast';
import GamepadContext from './GamepadContext';
type GamepadEventHandlers = Map<string, Map<string, (data?: string) => void>>;
type GamepadProviderProps = {
enabled: boolean;
onGuide?: () => void;
children: React.ReactNode;
};
const GamepadProvider = ({ enabled, onGuide, children }: GamepadProviderProps) => {
const { t } = useTranslation();
const toast = useToast();
const connectedGamepads = useRef<number>(0);
const lastButtonState = useRef<number[]>([]);
const lastButtonPressedTime = useRef<number>(0);
const axisTimer = useRef<number>(0);
const axisTimerRight = useRef<number>(0);
const eventHandlers = useRef<GamepadEventHandlers>(new Map());
const on = useCallback((event: string, id: string, callback: (data?: string) => void) => {
if (!eventHandlers.current.has(event)) {
eventHandlers.current.set(event, new Map());
}
const handlers = eventHandlers.current.get(event)!;
// Ensure only one handler per component
handlers.set(id, callback);
}, []);
const off = useCallback((event: string, id: string) => {
const handlersMap = eventHandlers.current.get(event);
handlersMap?.delete(id);
if (handlersMap?.size === 0) {
eventHandlers.current.delete(event);
}
}, []);
const emit = (event: string, data?: string) => {
if (eventHandlers.current.has(event)) {
const handlersMap = eventHandlers.current.get(event)!;
if (!handlersMap || handlersMap.size === 0) return;
const latestHandler = Array.from(handlersMap.values()).slice(-1)[0];
if (latestHandler) {
latestHandler(data);
}
}
};
const onGamepadConnected = useCallback(() => {
// @ts-expect-error show() expects no arguments
toast.show({
type: 'info',
title: t('GAMEPAD_CONNECTED'),
timeout: 4000,
});
}, [toast, t]);
const onGamepadDisconnected = useCallback(() => {
// @ts-expect-error show() expects no arguments
toast.show({
type: 'info',
title: t('GAMEPAD_DISCONNECTED'),
timeout: 4000,
});
}, [toast, t]);
useEffect(() => {
if (!enabled) return;
window.addEventListener('gamepadconnected', onGamepadConnected);
window.addEventListener('gamepaddisconnected', onGamepadDisconnected);
return () => {
window.removeEventListener('gamepadconnected', onGamepadConnected);
window.removeEventListener('gamepaddisconnected', onGamepadDisconnected);
};
}, [enabled, onGamepadConnected, onGamepadDisconnected]);
useEffect(() => {
if (onGuide) {
on('buttonX', 'gamepad-guide', onGuide);
}
return () => {
off('buttonX', 'gamepad-guide');
};
}, [onGuide]);
useEffect(() => {
if (!enabled || typeof navigator.getGamepads !== 'function') return;
let animationFrameId: number;
const updateStatus = () => {
if (document.hasFocus()) {
const currentTime = Date.now();
const controllers = Array.from(navigator.getGamepads()).filter(
(gp) => gp !== null
) as Gamepad[];
connectedGamepads.current = controllers.length;
controllers.forEach((controller, index) => {
const buttonsState = controller.buttons.reduce(
(buttons, button, i) => buttons | (button.pressed ? 1 << i : 0),
0
);
const processButton =
currentTime - lastButtonPressedTime.current > 250;
if (
lastButtonState.current[index] !== buttonsState ||
processButton
) {
lastButtonPressedTime.current = currentTime;
lastButtonState.current[index] = buttonsState;
if (buttonsState & (1 << 0)) emit('buttonA');
if (buttonsState & (1 << 1)) emit('buttonB');
if (buttonsState & (1 << 2)) emit('buttonX');
if (buttonsState & (1 << 3)) emit('buttonY');
if (buttonsState & (1 << 4)) emit('buttonLT');
if (buttonsState & (1 << 5)) emit('buttonRT');
}
const deadZone = 0.05;
const maxSpeed = 100;
let axisHandled = false;
if (controller.axes[0] < -deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[0]) * 2000)
) {
emit('analog', 'left');
axisHandled = true;
}
}
if (controller.axes[0] > deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[0]) * 2000)
) {
emit('analog', 'right');
axisHandled = true;
}
}
if (controller.axes[1] < -deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[1]) * 2000)
) {
emit('analog', 'up');
axisHandled = true;
}
}
if (controller.axes[1] > deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[1]) * 2000)
) {
emit('analog', 'down');
axisHandled = true;
}
}
if (axisHandled) axisTimer.current = currentTime;
let rightAxisHandled = false;
if (controller.axes.length > 2) {
if (controller.axes[2] < -deadZone) {
if (currentTime - axisTimerRight.current > maxSpeed + (2000 - Math.abs(controller.axes[2]) * 2000)) {
emit('analogRight', 'left');
rightAxisHandled = true;
}
}
if (controller.axes[2] > deadZone) {
if (currentTime - axisTimerRight.current > maxSpeed + (2000 - Math.abs(controller.axes[2]) * 2000)) {
emit('analogRight', 'right');
rightAxisHandled = true;
}
}
if (controller.axes[3] < -deadZone) {
if (currentTime - axisTimerRight.current > maxSpeed + (2000 - Math.abs(controller.axes[3]) * 2000)) {
emit('analogRight', 'up');
rightAxisHandled = true;
}
}
if (controller.axes[3] > deadZone) {
if (currentTime - axisTimerRight.current > maxSpeed + (2000 - Math.abs(controller.axes[3]) * 2000)) {
emit('analogRight', 'down');
rightAxisHandled = true;
}
}
}
if (rightAxisHandled) axisTimerRight.current = currentTime;
});
}
animationFrameId = requestAnimationFrame(updateStatus);
};
animationFrameId = requestAnimationFrame(updateStatus);
return () => {
cancelAnimationFrame(animationFrameId);
};
}, [enabled]);
return (
<GamepadContext.Provider value={{ on, off }}>
{children}
</GamepadContext.Provider>
);
};
export default GamepadProvider;

View file

@ -0,0 +1,9 @@
// Copyright (C) 2017-2026 Smart code 203358507
import GamepadProvider from './GamepadProvider';
import useGamepad from './useGamepad';
export {
GamepadProvider,
useGamepad
};

View file

@ -0,0 +1,10 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useContext } from 'react';
import GamepadContext from './GamepadContext';
const useGamepad = () => {
return useContext(GamepadContext);
};
export default useGamepad;

View file

@ -0,0 +1,11 @@
// Copyright (C) 2017-2026 Smart code 203358507
import useContentGamepadNavigation from './useContentGamepadNavigation';
import useVerticalNavGamepadNavigation from './useVerticalNavGamepadNavigation';
import useHorizontalNavGamepadNavigation from './useHorizontalNavGamepadNavigation';
export {
useContentGamepadNavigation,
useVerticalNavGamepadNavigation,
useHorizontalNavGamepadNavigation,
};

View file

@ -0,0 +1,140 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useEffect, useRef } from 'react';
import { useGamepad } from '../GamepadContext';
const FOCUSABLE = '[tabindex="0"]';
const getActiveScope = (fallback: HTMLDivElement | null): HTMLElement | null => {
const modal = document.querySelector<HTMLElement>('.modals-container');
if (modal && modal.children.length > 0) return modal;
const dropdown = fallback?.querySelector<HTMLElement>('[class*="dropdown"][class*="open"]');
if (dropdown) return dropdown;
return fallback;
};
const useContentGamepadNavigation = (
sectionRef: React.RefObject<HTMLDivElement>,
gamepadHandlerId: string
) => {
const gamepad = useGamepad();
const lastFocused = useRef<HTMLDivElement | null>(null);
const wasInOverlay = useRef(false);
useEffect(() => {
const handleGamepadNavigation = (
direction: 'left' | 'right' | 'up' | 'down'
) => {
const scope = getActiveScope(sectionRef.current);
const inOverlay = scope !== sectionRef.current;
if (inOverlay && !wasInOverlay.current) {
const focused = sectionRef.current?.querySelector<HTMLDivElement>(':focus');
if (focused) lastFocused.current = focused;
}
wasInOverlay.current = inOverlay;
const elements = Array.from(
scope?.querySelectorAll<HTMLDivElement>(FOCUSABLE) || []
);
if (elements.length === 0) return;
const activeElement = (scope ?? document)?.querySelector<HTMLDivElement>(':focus');
if (!activeElement) {
elements[0].focus();
return;
}
let closestElement: HTMLDivElement | null = null;
const cur = activeElement.getBoundingClientRect();
const cx = cur.left + cur.width / 2;
const cy = cur.top + cur.height / 2;
let closestDistance = Infinity;
elements.forEach((el) => {
if (el === activeElement) return;
const r = el.getBoundingClientRect();
const ex = r.left + r.width / 2;
const ey = r.top + r.height / 2;
const isCorrectDirection =
(direction === 'left' && ex < cx) ||
(direction === 'right' && ex > cx) ||
(direction === 'up' && ey < cy) ||
(direction === 'down' && ey > cy);
if (!isCorrectDirection) return;
const dx = ex - cx;
const dy = ey - cy;
const isHorizontal = direction === 'left' || direction === 'right';
const primary = isHorizontal ? Math.abs(dx) : Math.abs(dy);
const secondary = isHorizontal ? Math.abs(dy) : Math.abs(dx);
const distance = primary + secondary * 3;
if (distance < closestDistance) {
closestDistance = distance;
closestElement = el;
}
});
if (closestElement) {
closestElement.focus();
}
};
const onSelect = () => {
const scope = getActiveScope(sectionRef.current);
const inOverlay = scope !== sectionRef.current;
if (inOverlay && !wasInOverlay.current) {
const focused = sectionRef.current?.querySelector<HTMLDivElement>(':focus');
if (focused) lastFocused.current = focused;
}
wasInOverlay.current = inOverlay;
const elements = Array.from(
scope?.querySelectorAll<HTMLDivElement>(FOCUSABLE) || []
);
if (elements.length === 0) {
if (lastFocused.current) {
lastFocused.current.focus();
wasInOverlay.current = false;
}
return;
}
const activeElement = (scope ?? document)?.querySelector<HTMLDivElement>(':focus');
if (!activeElement) {
elements[0].focus();
return;
}
const isSelect = Array.from(activeElement.classList).some((cls) => cls.startsWith('select-input'));
if (!isSelect) {
activeElement?.click();
requestAnimationFrame(() => {
const stillInOverlay = getActiveScope(sectionRef.current) !== sectionRef.current;
if (!stillInOverlay && wasInOverlay.current && lastFocused.current) {
lastFocused.current.focus();
wasInOverlay.current = false;
}
});
}
};
gamepad?.on('analog', gamepadHandlerId, handleGamepadNavigation);
gamepad?.on('buttonA', gamepadHandlerId, onSelect);
return () => {
gamepad?.off('analog', gamepadHandlerId);
gamepad?.off('buttonA', gamepadHandlerId);
};
}, [gamepad, gamepadHandlerId, sectionRef]);
};
export default useContentGamepadNavigation;

View file

@ -0,0 +1,24 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useEffect } from 'react';
import { useGamepad } from '../GamepadContext';
import useFullscreen from 'stremio/common/useFullscreen';
const useHorizontalNavGamepadNavigation = (gamepadHandlerId: string, enableGoBack: boolean) => {
const gamepad = useGamepad();
const [fullscreen,,,toggleFullscreen] = useFullscreen();
useEffect(() => {
const goBack = () => enableGoBack && window.history.back();
gamepad?.on('buttonY', gamepadHandlerId, toggleFullscreen as () => void);
gamepad?.on('buttonB', gamepadHandlerId, goBack);
return () => {
gamepad?.off('buttonY', gamepadHandlerId);
gamepad?.off('buttonB', gamepadHandlerId);
};
}, [gamepad, gamepadHandlerId, enableGoBack, fullscreen]);
};
export default useHorizontalNavGamepadNavigation;

View file

@ -0,0 +1,35 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useEffect } from 'react';
import { useGamepad } from '../GamepadContext';
const ROUTES = ['search', 'board', 'discover', 'library', 'calendar', 'addons', 'settings'];
const useVerticalGamepadNavigation = (_sectionRef: React.RefObject<HTMLDivElement>, currentRoute: string) => {
const gamepad = useGamepad();
useEffect(() => {
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 (nextIndex !== currentIndex) {
document.dispatchEvent(new KeyboardEvent('keydown', { key: String(nextIndex), code: `Digit${nextIndex}`, bubbles: true }));
}
};
gamepad?.on('buttonLT', currentRoute, () => navigate('prev'));
gamepad?.on('buttonRT', currentRoute, () => navigate('next'));
return () => {
gamepad?.off('buttonLT', currentRoute);
gamepad?.off('buttonRT', currentRoute);
};
}, [gamepad, currentRoute]);
};
export default useVerticalGamepadNavigation;

View file

@ -5,6 +5,7 @@ const Core = require('./Core');
const DragAndDrop = require('./DragAndDrop');
const KeyboardShortcuts = require('./KeyboardShortcuts');
const { ServicesProvider, useServices } = require('./ServicesContext');
const { GamepadProvider, useGamepad } = require('./GamepadContext');
const Shell = require('./Shell');
module.exports = {
@ -14,5 +15,7 @@ module.exports = {
KeyboardShortcuts,
ServicesProvider,
useServices,
Shell
Shell,
GamepadProvider,
useGamepad,
};

View file

@ -24,6 +24,7 @@ type Settings = {
interfaceLanguage: string,
quitOnClose: boolean,
hideSpoilers: boolean,
gamepadSupport: boolean,
nextVideoNotificationDuration: number,
playInBackground: boolean,
playerType: string | null,