mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-05-10 03:01:45 +00:00
Merge pull request #882 from Stremio/feat/gamepad-support
Feat: Gamepad Support in Web v5
This commit is contained in:
commit
2075b1c521
30 changed files with 1237 additions and 45 deletions
|
|
@ -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>
|
||||
|
|
|
|||
214
src/App/GamepadModal/GamepadDiagram.tsx
Normal file
214
src/App/GamepadModal/GamepadDiagram.tsx
Normal 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;
|
||||
139
src/App/GamepadModal/GamepadModal.tsx
Normal file
139
src/App/GamepadModal/GamepadModal.tsx
Normal 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;
|
||||
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;
|
||||
214
src/App/GamepadModal/styles.less
Normal file
214
src/App/GamepadModal/styles.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,11 @@
|
|||
"name": "shortcuts",
|
||||
"label": "SETTINGS_SHORTCUT_SHORTCUTS",
|
||||
"combos": [["Ctrl", "/"]]
|
||||
},
|
||||
{
|
||||
"name": "gamepadGuide",
|
||||
"label": "GAMEPAD_ACTION_GUIDE",
|
||||
"combos": [["Ctrl", "G"]]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'])}>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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, '-')
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
10
src/services/GamepadContext/GamepadContext.ts
Normal file
10
src/services/GamepadContext/GamepadContext.ts
Normal 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;
|
||||
226
src/services/GamepadContext/GamepadProvider.tsx
Normal file
226
src/services/GamepadContext/GamepadProvider.tsx
Normal 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;
|
||||
9
src/services/GamepadContext/index.tsx
Normal file
9
src/services/GamepadContext/index.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright (C) 2017-2026 Smart code 203358507
|
||||
|
||||
import GamepadProvider from './GamepadProvider';
|
||||
import useGamepad from './useGamepad';
|
||||
|
||||
export {
|
||||
GamepadProvider,
|
||||
useGamepad
|
||||
};
|
||||
10
src/services/GamepadContext/useGamepad.tsx
Normal file
10
src/services/GamepadContext/useGamepad.tsx
Normal 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;
|
||||
11
src/services/GamepadNavigation/index.tsx
Normal file
11
src/services/GamepadNavigation/index.tsx
Normal 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,
|
||||
};
|
||||
140
src/services/GamepadNavigation/useContentGamepadNavigation.tsx
Normal file
140
src/services/GamepadNavigation/useContentGamepadNavigation.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
1
src/types/models/Ctx.d.ts
vendored
1
src/types/models/Ctx.d.ts
vendored
|
|
@ -24,6 +24,7 @@ type Settings = {
|
|||
interfaceLanguage: string,
|
||||
quitOnClose: boolean,
|
||||
hideSpoilers: boolean,
|
||||
gamepadSupport: boolean,
|
||||
nextVideoNotificationDuration: number,
|
||||
playInBackground: boolean,
|
||||
playerType: string | null,
|
||||
|
|
|
|||
Loading…
Reference in a new issue