diff --git a/src/App/App.js b/src/App/App.js index 3e816be9f..09aca6c1c 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -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 = () => { - - - - - + + { + shortcutModalOpen && + } + + + + + + diff --git a/src/App/ShortcutsModal/ShortcutsModal.tsx b/src/App/ShortcutsModal/ShortcutsModal.tsx new file mode 100644 index 000000000..5fec24837 --- /dev/null +++ b/src/App/ShortcutsModal/ShortcutsModal.tsx @@ -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(( +
+
+ +
+
+
+ {t('SETTINGS_NAV_SHORTCUTS')} +
+ + +
+ +
+ { + grouped.map(({ name, label, shortcuts }) => ( + + )) + } +
+
+
+ ), document.body); +}; + +export default ShortcutsModal; diff --git a/src/App/ShortcutsModal/index.ts b/src/App/ShortcutsModal/index.ts new file mode 100644 index 000000000..5a7549fac --- /dev/null +++ b/src/App/ShortcutsModal/index.ts @@ -0,0 +1,2 @@ +import ShortcutsModal from './ShortcutsModal'; +export default ShortcutsModal; diff --git a/src/App/ShortcutsModal/styles.less b/src/App/ShortcutsModal/styles.less new file mode 100644 index 000000000..ebbc19c62 --- /dev/null +++ b/src/App/ShortcutsModal/styles.less @@ -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; + } + } +} \ No newline at end of file diff --git a/src/common/Shortcuts/Shortcuts.tsx b/src/common/Shortcuts/Shortcuts.tsx new file mode 100644 index 000000000..f41e08271 --- /dev/null +++ b/src/common/Shortcuts/Shortcuts.tsx @@ -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({} 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 ( + + {children} + + ); +}; + +const useShortcuts = () => { + return useContext(ShortcutsContext); +}; + +export { + ShortcutsProvider, + useShortcuts +}; diff --git a/src/common/Shortcuts/index.ts b/src/common/Shortcuts/index.ts new file mode 100644 index 000000000..f7fa38a18 --- /dev/null +++ b/src/common/Shortcuts/index.ts @@ -0,0 +1,5 @@ +import { ShortcutsProvider, useShortcuts } from './Shortcuts'; +export { + ShortcutsProvider, + useShortcuts, +}; diff --git a/src/common/Shortcuts/shortcuts.ts b/src/common/Shortcuts/shortcuts.ts new file mode 100644 index 000000000..d918733af --- /dev/null +++ b/src/common/Shortcuts/shortcuts.ts @@ -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; diff --git a/src/common/Shortcuts/types.d.ts b/src/common/Shortcuts/types.d.ts new file mode 100644 index 000000000..e4180616d --- /dev/null +++ b/src/common/Shortcuts/types.d.ts @@ -0,0 +1,11 @@ +type Shortcut = { + name: string, + label: string, + combos: string[][], +}; + +type ShortcutGroup = { + name: string, + label: string, + shortcuts: Shortcut[], +}; diff --git a/src/common/index.js b/src/common/index.js index 25df5c158..0b9cb252f 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -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, diff --git a/src/components/ShortcutsGroup/Combos/Combos.less b/src/components/ShortcutsGroup/Combos/Combos.less new file mode 100644 index 000000000..a862d54ca --- /dev/null +++ b/src/components/ShortcutsGroup/Combos/Combos.less @@ -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; + } + } +} \ No newline at end of file diff --git a/src/components/ShortcutsGroup/Combos/Combos.tsx b/src/components/ShortcutsGroup/Combos/Combos.tsx new file mode 100644 index 000000000..0168441bc --- /dev/null +++ b/src/components/ShortcutsGroup/Combos/Combos.tsx @@ -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 ( +
+ { + combos.map((keys, index) => ( +
+ + { + index < (combos.length - 1) && ( +
+ { t('SETTINGS_SHORTCUT_OR') } +
+ ) + } +
+ )) + } +
+ ); +}; + +export default Combos; diff --git a/src/components/ShortcutsGroup/Combos/Keys/Keys.less b/src/components/ShortcutsGroup/Combos/Keys/Keys.less new file mode 100644 index 000000000..7bb8c76e7 --- /dev/null +++ b/src/components/ShortcutsGroup/Combos/Keys/Keys.less @@ -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); +} \ No newline at end of file diff --git a/src/components/ShortcutsGroup/Combos/Keys/Keys.tsx b/src/components/ShortcutsGroup/Combos/Keys/Keys.tsx new file mode 100644 index 000000000..71ec610da --- /dev/null +++ b/src/components/ShortcutsGroup/Combos/Keys/Keys.tsx @@ -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 = 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) => ( + + + {keyLabelMap[key] ?? key.toUpperCase()} + + { + index < (filteredKeys.length - 1) && ( +
+ { + isRange ? t('SETTINGS_SHORTCUT_TO') : '+' + } +
+ ) + } +
+ )) + ); +}; + +export default Keys; diff --git a/src/components/ShortcutsGroup/Combos/Keys/index.ts b/src/components/ShortcutsGroup/Combos/Keys/index.ts new file mode 100644 index 000000000..ba8d58731 --- /dev/null +++ b/src/components/ShortcutsGroup/Combos/Keys/index.ts @@ -0,0 +1,2 @@ +import Keys from './Keys'; +export default Keys; diff --git a/src/components/ShortcutsGroup/Combos/index.ts b/src/components/ShortcutsGroup/Combos/index.ts new file mode 100644 index 000000000..c66667f91 --- /dev/null +++ b/src/components/ShortcutsGroup/Combos/index.ts @@ -0,0 +1,2 @@ +import Combos from './Combos'; +export default Combos; diff --git a/src/components/ShortcutsGroup/ShortcutsGroup.less b/src/components/ShortcutsGroup/ShortcutsGroup.less new file mode 100644 index 000000000..f0fdd975c --- /dev/null +++ b/src/components/ShortcutsGroup/ShortcutsGroup.less @@ -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; + } + } + } +} diff --git a/src/components/ShortcutsGroup/ShortcutsGroup.tsx b/src/components/ShortcutsGroup/ShortcutsGroup.tsx new file mode 100644 index 000000000..069d5d1e8 --- /dev/null +++ b/src/components/ShortcutsGroup/ShortcutsGroup.tsx @@ -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 ( +
+
+ {t(label)} +
+ +
+ { + shortcuts.map(({ name, label, combos }) => ( +
+
+ {t(label)} +
+ +
+ )) + } +
+
+ ); +}; + +export default ShortcutsGroup; diff --git a/src/components/ShortcutsGroup/index.ts b/src/components/ShortcutsGroup/index.ts new file mode 100644 index 000000000..11f8d0678 --- /dev/null +++ b/src/components/ShortcutsGroup/index.ts @@ -0,0 +1,2 @@ +import ShortcutsGroup from './ShortcutsGroup'; +export default ShortcutsGroup; diff --git a/src/components/index.ts b/src/components/index.ts index a5638007e..a47c2c709 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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, diff --git a/src/routes/Settings/Shortcuts/Shortcuts.less b/src/routes/Settings/Shortcuts/Shortcuts.less index 40d97987d..186cfa837 100644 --- a/src/routes/Settings/Shortcuts/Shortcuts.less +++ b/src/routes/Settings/Shortcuts/Shortcuts.less @@ -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; } \ No newline at end of file diff --git a/src/routes/Settings/Shortcuts/Shortcuts.tsx b/src/routes/Settings/Shortcuts/Shortcuts.tsx index d852280a6..a0599a503 100644 --- a/src/routes/Settings/Shortcuts/Shortcuts.tsx +++ b/src/routes/Settings/Shortcuts/Shortcuts.tsx @@ -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((_, ref) => { - const { t } = useTranslation(); + const { grouped } = useShortcuts(); return (
- - - - - - - - - - - - - - + { + grouped.map(({ name, label, shortcuts }) => ( + + )) + }
); }); diff --git a/tsconfig.json b/tsconfig.json index d55ac9356..c4b0a8626 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "lib": [ "ES2016", "DOM", "DOM.Iterable"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "jsx": "react", "baseUrl": "./src", "outDir": "./dist",