feat: add shortcuts modal

This commit is contained in:
Tim 2025-10-12 11:55:57 +02:00
parent 10faa1c105
commit 9fa0e46423
22 changed files with 580 additions and 124 deletions

View file

@ -6,11 +6,12 @@ const { useTranslation } = require('react-i18next');
const { Router } = require('stremio-router');
const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services');
const { NotFound } = require('stremio/routes');
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender, useShell } = require('stremio/common');
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, ShortcutsProvider, CONSTANTS, withCoreSuspender, useShell, useBinaryState } = require('stremio/common');
const ServicesToaster = require('./ServicesToaster');
const DeepLinkHandler = require('./DeepLinkHandler');
const SearchParamsHandler = require('./SearchParamsHandler');
const { default: UpdaterBanner } = require('./UpdaterBanner');
const { default: ShortcutsModal } = require('./ShortcutsModal');
const ErrorDialog = require('./ErrorDialog');
const withProtectedRoutes = require('./withProtectedRoutes');
const routerViewsConfig = require('./routerViewsConfig');
@ -38,6 +39,14 @@ const App = () => {
};
}, []);
const [initialized, setInitialized] = React.useState(false);
const [shortcutModalOpen,, closeShortcutsModal, toggleShortcutModal] = useBinaryState(false);
const onShortcut = React.useCallback((name) => {
if (name === 'shortcuts') {
toggleShortcutModal();
}
}, [toggleShortcutModal]);
React.useEffect(() => {
let prevPath = window.location.hash.slice(1);
const onLocationHashChange = () => {
@ -203,15 +212,20 @@ 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}
/>
<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>
</FileDropProvider>
</TooltipProvider>
</ToastProvider>

View file

@ -0,0 +1,59 @@
// Copyright (C) 2017-2023 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 { useShortcuts } from 'stremio/common';
import { Button, ShortcutsGroup } from 'stremio/components';
import styles from './styles.less';
type Props = {
onClose: () => void,
};
const ShortcutsModal = ({ onClose }: Props) => {
const { t } = useTranslation();
const { grouped } = useShortcuts();
useEffect(() => {
const onKeyDown = ({ key }: KeyboardEvent) => {
key === 'Escape' && onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
return createPortal((
<div className={styles['shortcuts-modal']}>
<div className={styles['backdrop']} onClick={onClose} />
<div className={styles['container']}>
<div className={styles['header']}>
<div className={styles['title']}>
{t('SETTINGS_NAV_SHORTCUTS')}
</div>
<Button className={styles['close-button']} title={t('BUTTON_CLOSE')} onClick={onClose}>
<Icon className={styles['icon']} name={'close'} />
</Button>
</div>
<div className={styles['content']}>
{
grouped.map(({ name, label, shortcuts }) => (
<ShortcutsGroup
key={name}
label={label}
shortcuts={shortcuts}
/>
))
}
</div>
</div>
</div>
), document.body);
};
export default ShortcutsModal;

View file

@ -0,0 +1,2 @@
import ShortcutsModal from './ShortcutsModal';
export default ShortcutsModal;

View file

@ -0,0 +1,91 @@
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.shortcuts-modal {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
.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: 80%;
max-width: 80%;
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: row;
flex-wrap: wrap;
gap: 3rem;
padding: 0 2.5rem;
padding-bottom: 2rem;
overflow-y: auto;
}
}
}

View file

@ -0,0 +1,54 @@
import React, { createContext, useCallback, useContext, useEffect } from 'react';
import shortcuts from './shortcuts';
const SHORTCUTS = shortcuts.map(({ shortcuts }) => shortcuts).flat();
export type ShortcutName = string;
export type ShortcutListener = () => void;
interface ShortcutsContext {
grouped: ShortcutGroup[],
}
const ShortcutsContext = createContext<ShortcutsContext>({} as ShortcutsContext);
type Props = {
children: JSX.Element,
onShortcut: (name: ShortcutName) => void,
};
const ShortcutsProvider = ({ children, onShortcut }: Props) => {
const onKeyDown = useCallback(({ ctrlKey, shiftKey, key }: KeyboardEvent) => {
SHORTCUTS.forEach(({ name, combos }) => combos.forEach((keys) => {
const modifers = (keys.includes('Ctrl') ? ctrlKey : true)
&& (keys.includes('Shift') ? shiftKey : true);
if (modifers && keys.includes(key.toUpperCase())) {
onShortcut(name as ShortcutName);
}
}));
}, [onShortcut]);
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [onKeyDown]);
return (
<ShortcutsContext.Provider value={{ grouped: shortcuts }}>
{children}
</ShortcutsContext.Provider>
);
};
const useShortcuts = () => {
return useContext(ShortcutsContext);
};
export {
ShortcutsProvider,
useShortcuts
};

View file

@ -0,0 +1,5 @@
import { ShortcutsProvider, useShortcuts } from './Shortcuts';
export {
ShortcutsProvider,
useShortcuts,
};

View file

@ -0,0 +1,91 @@
const shortcuts: ShortcutGroup[] = [
{
name: 'general',
label: 'SETTINGS_NAV_GENERAL',
shortcuts: [
{
name: 'navigateTabs',
label: 'SETTINGS_SHORTCUT_NAVIGATE_MENUS',
combos: [['1', '2', '3', '4', '5', '6']],
},
{
name: 'navigateSearch',
label: 'SETTINGS_SHORTCUT_GO_TO_SEARCH',
combos: [['0']],
},
{
name: 'fullscreen',
label: 'SETTINGS_SHORTCUT_FULLSCREEN',
combos: [['F']],
},
{
name: 'exit',
label: 'SETTINGS_SHORTCUT_EXIT_BACK',
combos: [['Escape']],
},
{
name: 'shortcuts',
label: 'SETTINGS_SHORTCUT_SHORTCUTS',
combos: [['Ctrl', '?']],
},
]
},
{
name: 'player',
label: 'SETTINGS_NAV_PLAYER',
shortcuts: [
{
name: 'playPause',
label: 'SETTINGS_SHORTCUT_PLAY_PAUSE',
combos: [['Space']],
},
{
name: 'seekForward',
label: 'SETTINGS_SHORTCUT_SEEK_FORWARD',
combos: [['ArrowRight'], ['Shift', 'ArrowRight']],
},
{
name: 'seekBackward',
label: 'SETTINGS_SHORTCUT_SEEK_BACKWARD',
combos: [['ArrowLeft'], ['Shift', 'ArrowLeft']],
},
{
name: 'volumeUp',
label: 'SETTINGS_SHORTCUT_VOLUME_UP',
combos: [['ArrowUp']],
},
{
name: 'volumeDown',
label: 'SETTINGS_SHORTCUT_VOLUME_DOWN',
combos: [['ArrowDown']],
},
{
name: 'subtitlesSize',
label: 'SETTINGS_SHORTCUT_SUBTITLES_SIZE',
combos: [['-'], ['=']],
},
{
name: 'subtitlesDelay',
label: 'SETTINGS_SHORTCUT_SUBTITLES_DELAY',
combos: [['G'], ['H']],
},
{
name: 'subtitlesMenu',
label: 'SETTINGS_SHORTCUT_MENU_SUBTITLES',
combos: [['S']],
},
{
name: 'audioMenu',
label: 'SETTINGS_SHORTCUT_MENU_AUDIO',
combos: [['A']],
},
{
name: 'infoMenu',
label: 'SETTINGS_SHORTCUT_MENU_INFO',
combos: [['I']],
},
]
},
];
export default shortcuts;

11
src/common/Shortcuts/types.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
type Shortcut = {
name: string,
label: string,
combos: string[][],
};
type ShortcutGroup = {
name: string,
label: string,
shortcuts: Shortcut[],
};

View file

@ -4,6 +4,7 @@ const { FileDropProvider, onFileDrop } = require('./FileDrop');
const { PlatformProvider, usePlatform } = require('./Platform');
const { ToastProvider, useToast } = require('./Toast');
const { TooltipProvider, Tooltip } = require('./Tooltips');
const { ShortcutsProvider, useShortcuts } = require('./Shortcuts');
const comparatorWithPriorities = require('./comparatorWithPriorities');
const CONSTANTS = require('./CONSTANTS');
const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender');
@ -35,6 +36,8 @@ module.exports = {
onFileDrop,
PlatformProvider,
usePlatform,
ShortcutsProvider,
useShortcuts,
ToastProvider,
useToast,
TooltipProvider,

View file

@ -0,0 +1,22 @@
.combos {
position: relative;
display: flex;
overflow: visible;
.combo {
position: relative;
display: flex;
overflow: visible;
.separator {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 3.5rem;
font-size: 1rem;
color: var(--primary-foreground-color);
opacity: 0.6;
}
}
}

View file

@ -0,0 +1,33 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import Keys from './Keys';
import styles from './Combos.less';
type Props = {
combos: string[][],
};
const Combos = ({ combos }: Props) => {
const { t } = useTranslation();
return (
<div className={styles['combos']}>
{
combos.map((keys, index) => (
<div className={styles['combo']} key={index}>
<Keys keys={keys} />
{
index < (combos.length - 1) && (
<div className={styles['separator']}>
{ t('SETTINGS_SHORTCUT_OR') }
</div>
)
}
</div>
))
}
</div>
);
};
export default Combos;

View file

@ -0,0 +1,26 @@
kbd {
flex: none;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.5rem;
min-width: 2.5rem;
padding: 0 1rem;
font-size: 1rem;
font-weight: 500;
color: var(--primary-foreground-color);
border-radius: 0.25em;
box-shadow: 0 4px 0 1px rgba(255, 255, 255, 0.1);
background-color: var(--overlay-color);
}
.separator {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
font-size: 1rem;
color: var(--primary-foreground-color);
}

View file

@ -0,0 +1,51 @@
import React, { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './Keys.less';
type Props = {
keys: string[],
};
const Keys = ({ keys }: Props) => {
const { t } = useTranslation();
const keyLabelMap: Record<string, string> = useMemo(() => ({
'Shift': `${t('SETTINGS_SHORTCUT_SHIFT')}`,
'Space': t('SETTINGS_SHORTCUT_SPACE'),
'Ctrl': t('SETTINGS_SHORTCUT_CTRL'),
'Escape': t('SETTINGS_SHORTCUT_ESC'),
'ArrowUp': '↑',
'ArrowDown': '↓',
'ArrowLeft': '←',
'ArrowRight': '→',
}), [t]);
const isRange = useMemo(() => {
return keys.length > 1 && keys.every((key) => !Number.isNaN(parseInt(key)));
}, [keys]);
const filteredKeys = useMemo(() => {
return isRange ? [keys[0], keys[keys.length - 1]] : keys;
}, [keys, isRange]);
return (
filteredKeys.map((key, index) => (
<Fragment key={key}>
<kbd>
{keyLabelMap[key] ?? key.toUpperCase()}
</kbd>
{
index < (filteredKeys.length - 1) && (
<div className={styles['separator']}>
{
isRange ? t('SETTINGS_SHORTCUT_TO') : '+'
}
</div>
)
}
</Fragment>
))
);
};
export default Keys;

View file

@ -0,0 +1,2 @@
import Keys from './Keys';
export default Keys;

View file

@ -0,0 +1,2 @@
import Combos from './Combos';
export default Combos;

View file

@ -0,0 +1,44 @@
.shortcuts-group {
flex: 1 1 0;
position: relative;
min-width: 30rem;
display: flex;
flex-direction: column;
gap: 2rem;
overflow: visible;
.title {
flex: none;
display: flex;
font-size: 1rem;
font-weight: 400;
color: var(--primary-foreground-color);
opacity: 0.6;
}
.shortcuts {
position: relative;
display: flex;
flex-direction: column;
gap: 2rem;
overflow: visible;
.shortcut {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
overflow: visible;
.label {
position: relative;
font-size: 1rem;
color: var(--primary-foreground-color);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
}

View file

@ -0,0 +1,38 @@
import React from 'react';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import Combos from './Combos';
import styles from './ShortcutsGroup.less';
type Props = {
className?: string,
label: string,
shortcuts: Shortcut[],
};
const ShortcutsGroup = ({ className, label, shortcuts }: Props) => {
const { t } = useTranslation();
return (
<div className={classNames(className, styles['shortcuts-group'])}>
<div className={styles['title']}>
{t(label)}
</div>
<div className={styles['shortcuts']}>
{
shortcuts.map(({ name, label, combos }) => (
<div className={styles['shortcut']} key={name}>
<div className={styles['label']}>
{t(label)}
</div>
<Combos combos={combos} />
</div>
))
}
</div>
</div>
);
};
export default ShortcutsGroup;

View file

@ -0,0 +1,2 @@
import ShortcutsGroup from './ShortcutsGroup';
export default ShortcutsGroup;

View file

@ -25,6 +25,7 @@ import RadioButton from './RadioButton';
import SearchBar from './SearchBar';
import SharePrompt from './SharePrompt';
import Slider from './Slider';
import ShortcutsGroup from './ShortcutsGroup';
import TextInput from './TextInput';
import Toggle from './Toggle';
import Transition from './Transition';
@ -59,6 +60,7 @@ export {
SearchBar,
SharePrompt,
Slider,
ShortcutsGroup,
TextInput,
Toggle,
Transition,

View file

@ -1,27 +1,4 @@
.shortcut-container {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
overflow: visible;
kbd {
flex: 0 1 auto;
height: 2.5rem;
min-width: 2.5rem;
line-height: 2.5rem;
padding: 0 1rem;
font-weight: 500;
color: var(--primary-foreground-color);
border-radius: 0.25em;
box-shadow: 0 4px 0 1px var(--modal-background-color);
background-color: var(--overlay-color);
}
.label {
flex: none;
margin: 0 1rem;
white-space: nowrap;
color: var(--primary-foreground-color);
}
.shortcuts-group {
width: 100%;
margin-bottom: 3rem;
}

View file

@ -1,97 +1,24 @@
import React, { forwardRef } from 'react';
import { Section, Option } from '../components';
import { Section } from '../components';
import { ShortcutsGroup } from 'stremio/components';
import { useShortcuts } from 'stremio/common';
import styles from './Shortcuts.less';
import { useTranslation } from 'react-i18next';
const Shortcuts = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
const { grouped } = useShortcuts();
return (
<Section ref={ref} label={'SETTINGS_NAV_SHORTCUTS'}>
<Option label={'SETTINGS_SHORTCUT_PLAY_PAUSE'}>
<div className={styles['shortcut-container']}>
<kbd>{t('SETTINGS_SHORTCUT_SPACE')}</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_SEEK_FORWARD'}>
<div className={styles['shortcut-container']}>
<kbd></kbd>
<div className={styles['label']}>{t('SETTINGS_SHORTCUT_OR')}</div>
<kbd> {t('SETTINGS_SHORTCUT_SHIFT')}</kbd>
<div className={styles['label']}>+</div>
<kbd></kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_SEEK_BACKWARD'}>
<div className={styles['shortcut-container']}>
<kbd></kbd>
<div className={styles['label']}>{t('SETTINGS_SHORTCUT_OR')}</div>
<kbd> {t('SETTINGS_SHORTCUT_SHIFT')}</kbd>
<div className={styles['label']}>+</div>
<kbd></kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_VOLUME_UP'}>
<div className={styles['shortcut-container']}>
<kbd></kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_VOLUME_DOWN'}>
<div className={styles['shortcut-container']}>
<kbd></kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_MENU_SUBTITLES'}>
<div className={styles['shortcut-container']}>
<kbd>S</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_MENU_AUDIO'}>
<div className={styles['shortcut-container']}>
<kbd>A</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_MENU_INFO'}>
<div className={styles['shortcut-container']}>
<kbd>I</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_FULLSCREEN'}>
<div className={styles['shortcut-container']}>
<kbd>F</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_SUBTITLES_SIZE'}>
<div className={styles['shortcut-container']}>
<kbd>-</kbd>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_AND') }</div>
<kbd>=</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_SUBTITLES_DELAY'}>
<div className={styles['shortcut-container']}>
<kbd>G</kbd>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_AND') }</div>
<kbd>H</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_NAVIGATE_MENUS'}>
<div className={styles['shortcut-container']}>
<kbd>1</kbd>
<div className={styles['label']}>{t('SETTINGS_SHORTCUT_TO')}</div>
<kbd>6</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_GO_TO_SEARCH'}>
<div className={styles['shortcut-container']}>
<kbd>0</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_EXIT_BACK'}>
<div className={styles['shortcut-container']}>
<kbd>{t('SETTINGS_SHORTCUT_ESC')}</kbd>
</div>
</Option>
{
grouped.map(({ name, label, shortcuts }) => (
<ShortcutsGroup
key={name}
className={styles['shortcuts-group']}
label={label}
shortcuts={shortcuts}
/>
))
}
</Section>
);
});

View file

@ -1,6 +1,6 @@
{
"compilerOptions": {
"lib": [ "ES2016", "DOM", "DOM.Iterable"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"jsx": "react",
"baseUrl": "./src",
"outDir": "./dist",