mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-04-18 04:42:01 +00:00
Merge ddf842b0c7 into be072e8391
This commit is contained in:
commit
3b3ce6f72a
20 changed files with 580 additions and 47 deletions
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
10
src/services/GamepadContext/GamepadContext.ts
Normal file
10
src/services/GamepadContext/GamepadContext.ts
Normal 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;
|
||||
188
src/services/GamepadContext/GamepadProvider.tsx
Normal file
188
src/services/GamepadContext/GamepadProvider.tsx
Normal 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;
|
||||
7
src/services/GamepadContext/index.tsx
Normal file
7
src/services/GamepadContext/index.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
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-2025 Smart code 203358507
|
||||
|
||||
import { useContext } from 'react';
|
||||
import GamepadContext from './GamepadContext';
|
||||
|
||||
const useGamepad = () => {
|
||||
return useContext(GamepadContext);
|
||||
};
|
||||
|
||||
export default useGamepad;
|
||||
9
src/services/GamepadNavigation/index.tsx
Normal file
9
src/services/GamepadNavigation/index.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import useContentGamepadNavigation from './useContentGamepadNavigation';
|
||||
import useVerticalNavGamepadNavigation from './useVerticalNavGamepadNavigation';
|
||||
import useHorizontalNavGamepadNavigation from './useHorizontalNavGamepadNavigation';
|
||||
|
||||
export {
|
||||
useContentGamepadNavigation,
|
||||
useVerticalNavGamepadNavigation,
|
||||
useHorizontalNavGamepadNavigation,
|
||||
};
|
||||
122
src/services/GamepadNavigation/useContentGamepadNavigation.tsx
Normal file
122
src/services/GamepadNavigation/useContentGamepadNavigation.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -23,6 +23,7 @@ type Settings = {
|
|||
interfaceLanguage: string,
|
||||
quitOnClose: boolean,
|
||||
hideSpoilers: boolean,
|
||||
gamepadSupport: boolean,
|
||||
nextVideoNotificationDuration: number,
|
||||
playInBackground: boolean,
|
||||
playerType: string | null,
|
||||
|
|
|
|||
Loading…
Reference in a new issue