This commit is contained in:
botsy 2025-08-28 09:08:14 +00:00 committed by GitHub
commit 3b3ce6f72a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 580 additions and 47 deletions

9
package-lock.json generated
View file

@ -12,7 +12,7 @@
"@babel/runtime": "7.26.0",
"@sentry/browser": "8.42.0",
"@stremio/stremio-colors": "5.2.0",
"@stremio/stremio-core-web": "0.49.4",
"@stremio/stremio-core-web": "https://stremio.github.io/stremio-core/stremio-core-web/feat/settings-gamepad-support/dev/stremio-stremio-core-web-0.49.2.tgz",
"@stremio/stremio-icons": "5.7.1",
"@stremio/stremio-video": "0.0.61",
"a-color-picker": "1.2.1",
@ -3374,9 +3374,10 @@
"integrity": "sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg=="
},
"node_modules/@stremio/stremio-core-web": {
"version": "0.49.4",
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.4.tgz",
"integrity": "sha512-K9LJGKXs8juV3pZOHH6thWTwOShAhjFt9bLL6K1VlORAe6AiieZ2uRp9wdOwFmPX+UgzWLIOd0r2aFXJ4OsJCw==",
"version": "0.49.2",
"resolved": "https://stremio.github.io/stremio-core/stremio-core-web/feat/settings-gamepad-support/dev/stremio-stremio-core-web-0.49.2.tgz",
"integrity": "sha512-v09u/eOCRRzXoTKHN6CFju13jzzmgG34V7RT0O1/ryJhJxWQJ0+2y1yUQkwUFieVvYsdZgoNL2x58rPEC/0iig==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "7.24.1"
}

View file

@ -17,7 +17,7 @@
"@babel/runtime": "7.26.0",
"@sentry/browser": "8.42.0",
"@stremio/stremio-colors": "5.2.0",
"@stremio/stremio-core-web": "0.49.4",
"@stremio/stremio-core-web": "https://stremio.github.io/stremio-core/stremio-core-web/feat/settings-gamepad-support/dev/stremio-stremio-core-web-0.49.2.tgz",
"@stremio/stremio-icons": "5.7.1",
"@stremio/stremio-video": "0.0.61",
"a-color-picker": "1.2.1",

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, CONSTANTS, withCoreSuspender, useShell } = require('stremio/common');
const ServicesToaster = require('./ServicesToaster');
@ -21,6 +21,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;
}, []);
@ -132,6 +133,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');
}
@ -145,6 +150,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');
}
@ -203,15 +212,17 @@ const App = () => {
<ToastProvider className={styles['toasts-container']}>
<TooltipProvider className={styles['tooltip-container']}>
<FileDropProvider className={styles['file-drop-container']}>
<ServicesToaster />
<DeepLinkHandler />
<SearchParamsHandler />
<UpdaterBanner className={styles['updater-banner-container']} />
<RouterWithProtectedRoutes
className={styles['router']}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
<GamepadProvider enabled={gamepadSupportEnabled}>
<ServicesToaster />
<DeepLinkHandler />
<SearchParamsHandler />
<UpdaterBanner className={styles['updater-banner-container']} />
<RouterWithProtectedRoutes
className={styles['router']}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
</GamepadProvider>
</FileDropProvider>
</TooltipProvider>
</ToastProvider>

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 || '');
useVerticalNavGamepadNavigation(navRef, route || '');
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

@ -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

@ -4,7 +4,7 @@ const React = require('react');
const { useTranslation } = require('react-i18next');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { useServices } = require('stremio/services');
const { useServices, useContentGamepadNavigation } = require('stremio/services');
const { withCoreSuspender } = require('stremio/common');
const { VerticalNavBar, HorizontalNavBar, DelayedRenderer, Image, MetaPreview, ModalDialog } = require('stremio/components');
const StreamsList = require('./StreamsList');
@ -15,6 +15,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);
@ -94,6 +95,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
metaDetails.metaItem.content.content.background.length > 0
), [metaPath, metaDetails]);
useContentGamepadNavigation(contentRef, urlParams.path);
return (
<div className={styles['metadetails-container']}>
{
@ -115,7 +117,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
@ -218,6 +220,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
MetaDetails.propTypes = {
urlParams: PropTypes.shape({
path: PropTypes.string,
type: PropTypes.string,
id: PropTypes.string,
videoId: PropTypes.string

View file

@ -7,7 +7,7 @@ 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 { onFileDrop, useSettings, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell } = require('stremio/common');
const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components');
const BufferingLoader = require('./BufferingLoader');
@ -29,10 +29,13 @@ const styles = require('./styles');
const Video = require('./Video');
const { default: Indicator } = require('./Indicator/Indicator');
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]);
@ -318,6 +321,77 @@ 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]);
React.useEffect(() => {
gamepad.on('buttonA', GAMEPAD_HANDLER_ID, onPlayPause);
gamepad.on('analog', GAMEPAD_HANDLER_ID, onGamepadSeekAndVol);
return () => {
gamepad.off('buttonA', GAMEPAD_HANDLER_ID);
gamepad.off('analog', GAMEPAD_HANDLER_ID);
};
}, [onPlayPause, onGamepadSeekAndVol]);
React.useEffect(() => {
setError(null);
video.unload();
@ -536,46 +610,27 @@ const Player = ({ urlParams, queryParams }) => {
const onKeyDown = (event) => {
switch (event.code) {
case 'Space': {
if (!menusOpen && !nextVideoPopupOpen && video.state.paused !== null) {
if (video.state.paused) {
onPlayRequested();
setSeeking(false);
} else {
onPauseRequested();
}
}
onPlayPause();
break;
}
case 'ArrowRight': {
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
const seekDuration = event.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration;
setSeeking(true);
onSeekRequested(video.state.time + seekDuration);
}
onSeekNext(event);
break;
}
case 'ArrowLeft': {
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
const seekDuration = event.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration;
setSeeking(true);
onSeekRequested(video.state.time - seekDuration);
}
onSeekPrev(event);
break;
}
case 'ArrowUp': {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
onVolumeChangeRequested(Math.min(video.state.volume + 5, 200));
}
onVolumeUp();
break;
}
case 'ArrowDown': {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
onVolumeChangeRequested(Math.max(video.state.volume - 5, 0));
}
onVolumeDown();
break;
}

View file

@ -25,6 +25,7 @@ const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
quitOnCloseToggle,
escExitFullscreenToggle,
hideSpoilersToggle,
gamepadSupportToggle,
} = useGeneralOptions(profile);
const [traktAuthStarted, setTraktAuthStarted] = useState(false);
@ -175,6 +176,12 @@ const General = 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 useGeneralOptions = (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-2025 Smart code 203358507
import { createContext } from 'react';
const GamepadContext = createContext<{
on: (event: string, id: string, callback: (data?: any) => void) => void;
off: (event: string, id: string) => void;
} | null>(null);
export default GamepadContext;

View file

@ -0,0 +1,188 @@
// Copyright (C) 2017-2025 Smart code 203358507
import React, { useEffect, useRef, useState, useCallback } from 'react';
import useToast from 'stremio/common/Toast/useToast';
import GamepadContext from './GamepadContext';
type GamepadEventHandlers = Map<string, Map<string, (data?: any) => void>>;
const GamepadProvider: React.FC<{
enabled: boolean;
children: React.ReactNode;
}> = ({ enabled, children }) => {
const toast = useToast();
const [connectedGamepads, setConnectedGamepads] = useState<number>(0);
const lastButtonState = useRef<number[]>([]);
const lastButtonPressedTime = useRef<number>(0);
const axisTimer = useRef<number>(0);
const eventHandlers = useRef<GamepadEventHandlers>(new Map());
const on = useCallback((event: string, id: string, callback: (data?: any) => 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?: any) => {
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 = () => {
// @ts-expect-error show() expects no arguments
toast.show({
type: 'info',
title: 'Gamepad detected',
timeout: 4000,
});
};
const onGamepadDisconnected = () => {
// @ts-expect-error show() expects no arguments
toast.show({
type: 'info',
title: 'Gamepad disconnected',
timeout: 4000,
});
};
useEffect(() => {
if (enabled) {
window.addEventListener('gamepadconnected', onGamepadConnected);
window.addEventListener('gamepaddisconnected', onGamepadDisconnected);
}
return () => {
if (enabled) {
window.removeEventListener('gamepadconnected', onGamepadConnected);
window.removeEventListener('gamepaddisconnected', onGamepadDisconnected);
}
};
}, [enabled]);
useEffect(() => {
if (typeof navigator.getGamepads !== 'function') return;
let animationFrameId: number;
if (enabled) {
const updateStatus = () => {
if (document.hasFocus()) {
const currentTime = Date.now();
const controllers = Array.from(navigator.getGamepads()).filter(
(gp) => gp !== null
) as Gamepad[];
if (controllers.length !== connectedGamepads) {
setConnectedGamepads(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;
});
}
animationFrameId = requestAnimationFrame(updateStatus);
};
animationFrameId = requestAnimationFrame(updateStatus);
}
return () => {
if (enabled) {
cancelAnimationFrame(animationFrameId);
}
};
}, [connectedGamepads, enabled]);
return (
<GamepadContext.Provider value={{ on, off }}>
{children}
</GamepadContext.Provider>
);
};
export default GamepadProvider;

View file

@ -0,0 +1,7 @@
import GamepadProvider from './GamepadProvider';
import useGamepad from './useGamepad';
export {
GamepadProvider,
useGamepad
};

View file

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

View file

@ -0,0 +1,9 @@
import useContentGamepadNavigation from './useContentGamepadNavigation';
import useVerticalNavGamepadNavigation from './useVerticalNavGamepadNavigation';
import useHorizontalNavGamepadNavigation from './useHorizontalNavGamepadNavigation';
export {
useContentGamepadNavigation,
useVerticalNavGamepadNavigation,
useHorizontalNavGamepadNavigation,
};

View file

@ -0,0 +1,122 @@
// Copyright (C) 2017-2025 Smart code 203358507
import { useEffect } from 'react';
import { useGamepad } from '../GamepadContext';
const useContentGamepadNavigation = (
sectionRef: React.RefObject<HTMLDivElement>,
gamepadHandlerId: string
) => {
const gamepad = useGamepad();
useEffect(() => {
const handleGamepadNavigation = (
direction: 'left' | 'right' | 'up' | 'down'
) => {
const elements = Array.from(
sectionRef.current?.querySelectorAll<HTMLDivElement>('[tabindex="0"]') || []
);
if (elements.length === 0) return;
const activeElement = sectionRef.current?.querySelector<HTMLDivElement>(':focus');
if (!activeElement) {
elements[0].focus();
return;
}
let closestElement: HTMLDivElement | null = null;
const currentRect = activeElement.getBoundingClientRect();
let closestDistance = Infinity;
elements.forEach((el) => {
if (el === activeElement) return;
const rect = el.getBoundingClientRect();
let distance = Infinity;
switch (direction) {
case 'left':
if (
rect.right <= currentRect.left &&
(rect.top === currentRect.top ||
(rect.top < currentRect.top && rect.bottom > currentRect.top)
)
) {
distance = currentRect.left - rect.right;
}
break;
case 'right':
if (
currentRect.right <= rect.left &&
(rect.top === currentRect.top ||
(rect.top < currentRect.top && rect.bottom > currentRect.top)
)
) {
distance = rect.left - currentRect.right;
}
break;
case 'up':
if (
rect.bottom <= currentRect.top &&
(rect.left === currentRect.left ||
(rect.left < currentRect.left && rect.right > currentRect.left)
)
) {
distance = currentRect.top - rect.bottom;
}
break;
case 'down':
if (
rect.top >= currentRect.bottom &&
(rect.left === currentRect.left ||
(rect.left < currentRect.left && rect.right > currentRect.left)
)
) {
distance = rect.top - currentRect.bottom;
}
break;
}
if (distance < closestDistance) {
closestDistance = distance;
closestElement = el;
}
});
if (closestElement) {
closestElement.focus();
}
};
const onSelect = () => {
const elements = Array.from(
sectionRef.current?.querySelectorAll<HTMLDivElement>('[tabindex="0"]') || []
);
if (elements.length === 0) return;
const activeElement = sectionRef.current?.querySelector<HTMLDivElement>(':focus');
if (!activeElement) {
elements[0].focus();
return;
}
const isActiveSelectElement = [activeElement.classList].some((className) => /^select-input/.test(className.toString()));
if (!isActiveSelectElement) {
activeElement?.click();
}
};
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-2025 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,55 @@
// Copyright (C) 2017-2025 Smart code 203358507
import { useEffect } from 'react';
import { useGamepad } from '../GamepadContext';
const useVerticalGamepadNavigation = (sectionRef: React.RefObject<HTMLDivElement>, gamepadHandlerId: string) => {
const gamepad = useGamepad();
useEffect(() => {
const focusableSelector = 'a';
const focusableElements = () =>
Array.from(sectionRef.current?.querySelectorAll(focusableSelector) || []);
const moveFocus = (direction: 'prev' | 'next') => {
const route = window.location.hash.replace('#/', '') || 'board';
const elements = focusableElements();
if (!elements.length || route !== gamepadHandlerId) return;
const currentIndex = elements.findIndex((item) => item.classList.contains('selected'));
let nextIndex = currentIndex;
if (direction === 'next')
nextIndex = (elements.length + currentIndex + 1) % elements.length;
if (direction === 'prev')
nextIndex = (elements.length + currentIndex - 1) % elements.length;
elements[nextIndex]?.click();
};
const handleKeyDown = (event) => {
if (!event.nativeEvent?.spatialNavigationPrevented) {
switch (event.key) {
case 'Tab':
moveFocus('next');
break;
default:
break;
}
}
};
document.addEventListener('keydown', handleKeyDown);
gamepad?.on('buttonLT', gamepadHandlerId, () => moveFocus('prev'));
gamepad?.on('buttonRT', gamepadHandlerId, () => moveFocus('next'));
return () => {
document.removeEventListener('keydown', handleKeyDown);
gamepad?.off('buttonLT', gamepadHandlerId);
gamepad?.off('buttonRT', gamepadHandlerId);
};
}, [gamepad, sectionRef]);
};
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

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