From ecd3a5139a8a44a5aad1ad7550262be933e5a0fa Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Fri, 17 Jul 2026 14:39:45 +0300 Subject: [PATCH] fix: support virtual keyboard shortcuts --- src/common/Fullscreen/FullscreenProvider.tsx | 7 +- src/common/Shortcuts/Shortcuts.tsx | 17 +++-- src/common/Shortcuts/index.ts | 2 + src/common/Shortcuts/keyboard.ts | 69 ++++++++++++++++++++ src/common/index.js | 3 +- src/routes/Player/Player.js | 12 ++-- 6 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 src/common/Shortcuts/keyboard.ts diff --git a/src/common/Fullscreen/FullscreenProvider.tsx b/src/common/Fullscreen/FullscreenProvider.tsx index c164dfd8bf..3a8b86d1cc 100644 --- a/src/common/Fullscreen/FullscreenProvider.tsx +++ b/src/common/Fullscreen/FullscreenProvider.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { withCoreSuspender } from '../CoreSuspender'; +import { getKeyboardShortcutKey } from '../Shortcuts'; import onShortcut from '../Shortcuts/onShortcut'; import useSettings from '../useSettings'; import FullscreenContext, { type FullscreenContextValue } from './FullscreenContext'; @@ -80,11 +81,13 @@ const FullscreenProvider = ({ children }: Props) => { }; const onKeyDown = (event: KeyboardEvent) => { - if (event.code === 'Escape' && escExitFullscreen) { + const keyboardKey = getKeyboardShortcutKey(event); + + if (keyboardKey === 'Escape' && escExitFullscreen) { exitFullscreen(); } - if (event.code === 'F11' && shell.active) { + if (keyboardKey === 'F11' && shell.active) { toggleFullscreen(); } }; diff --git a/src/common/Shortcuts/Shortcuts.tsx b/src/common/Shortcuts/Shortcuts.tsx index d145e874c9..588598d450 100644 --- a/src/common/Shortcuts/Shortcuts.tsx +++ b/src/common/Shortcuts/Shortcuts.tsx @@ -1,4 +1,5 @@ import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react'; +import { getKeyboardShortcutKey, getKeyboardShortcutKeys } from './keyboard'; import shortcuts from './shortcuts.json'; const SHORTCUTS = shortcuts.map(({ shortcuts }) => shortcuts).flat(); @@ -33,14 +34,17 @@ const ShortcutsProvider = ({ children, onShortcut }: Props) => { const listeners = useRef>>(new Map()); const lastRepeatTime = useRef>(new Map()); - const onKeyDown = useCallback(({ ctrlKey, shiftKey, altKey, metaKey, code, key, repeat }: KeyboardEvent) => { + const onKeyDown = useCallback((event: KeyboardEvent) => { + const { ctrlKey, shiftKey, altKey, metaKey, key, repeat } = event; if (isInputFocused()) return; + const shortcutKeys = getKeyboardShortcutKeys(event); + const repeatKey = getKeyboardShortcutKey(event); if (repeat) { const now = Date.now(); - const last = lastRepeatTime.current.get(code) ?? 0; + const last = lastRepeatTime.current.get(repeatKey) ?? 0; if (now - last < REPEAT_THROTTLE_MS) return; - lastRepeatTime.current.set(code, now); + lastRepeatTime.current.set(repeatKey, now); } SHORTCUTS.forEach(({ name, combos }) => combos.forEach((keys) => { @@ -48,8 +52,13 @@ const ShortcutsProvider = ({ children, onShortcut }: Props) => { && (keys.includes('Shift') === shiftKey) && !altKey && !metaKey; + const keyMatched = keys.some((shortcutKey) => ( + shortcutKey !== 'Ctrl' + && shortcutKey !== 'Shift' + && shortcutKeys.includes(shortcutKey) + )); - if (modifers && (keys.includes(code) || keys.includes(key.toUpperCase()))) { + if (modifers && keyMatched) { const combo = combos.indexOf(keys); listeners.current.get(name)?.forEach((listener) => listener(combo, key)); diff --git a/src/common/Shortcuts/index.ts b/src/common/Shortcuts/index.ts index 971003a38a..fd6dc16485 100644 --- a/src/common/Shortcuts/index.ts +++ b/src/common/Shortcuts/index.ts @@ -1,8 +1,10 @@ import { ShortcutsProvider, useShortcuts } from './Shortcuts'; +import { getKeyboardShortcutKey } from './keyboard'; import onShortcut from './onShortcut'; export { ShortcutsProvider, useShortcuts, onShortcut, + getKeyboardShortcutKey, }; diff --git a/src/common/Shortcuts/keyboard.ts b/src/common/Shortcuts/keyboard.ts new file mode 100644 index 0000000000..4fe0825b85 --- /dev/null +++ b/src/common/Shortcuts/keyboard.ts @@ -0,0 +1,69 @@ +const KEY_CODE_MAP: Record = { + 8: 'Backspace', + 27: 'Escape', + 32: 'Space', + 37: 'ArrowLeft', + 38: 'ArrowUp', + 39: 'ArrowRight', + 40: 'ArrowDown', + 122: 'F11', + 187: '=', + 189: '-', + 191: '/', + 219: '[', + 221: ']', +}; + +const KEY_MAP: Record = { + ' ': 'Space', + Spacebar: 'Space', + Esc: 'Escape', + Left: 'ArrowLeft', + Up: 'ArrowUp', + Right: 'ArrowRight', + Down: 'ArrowDown', +}; + +const keyFromKeyCode = (keyCode: number) => { + if (KEY_CODE_MAP[keyCode]) { + return KEY_CODE_MAP[keyCode]; + } + + if (keyCode >= 48 && keyCode <= 57) { + return String.fromCharCode(keyCode); + } + + if (keyCode >= 65 && keyCode <= 90) { + return String.fromCharCode(keyCode); + } + + if (keyCode >= 96 && keyCode <= 105) { + return String(keyCode - 96); + } + + return null; +}; + +const normalizeKeyboardKey = ({ key, keyCode, which }: KeyboardEvent) => { + const mappedKey = KEY_MAP[key] ?? key; + if (mappedKey && mappedKey !== 'Unidentified') { + return mappedKey.length === 1 ? mappedKey.toUpperCase() : mappedKey; + } + + return keyFromKeyCode(keyCode || which) ?? mappedKey; +}; + +const getKeyboardShortcutKey = (event: KeyboardEvent) => { + return event.code && event.code !== 'Unidentified' ? event.code : normalizeKeyboardKey(event); +}; + +const getKeyboardShortcutKeys = (event: KeyboardEvent) => { + const normalizedKey = normalizeKeyboardKey(event); + const code = event.code !== 'Unidentified' ? event.code : ''; + return code && code !== normalizedKey ? [code, normalizedKey] : [normalizedKey]; +}; + +export { + getKeyboardShortcutKey, + getKeyboardShortcutKeys, +}; diff --git a/src/common/index.js b/src/common/index.js index bca92c177a..fefd96ec7a 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -5,7 +5,7 @@ const { FullscreenProvider, useFullscreen } = require('./Fullscreen'); const { PlatformProvider, usePlatform } = require('./Platform'); const { ToastProvider, useToast } = require('./Toast'); const { TooltipProvider, Tooltip } = require('./Tooltips'); -const { ShortcutsProvider, useShortcuts, onShortcut } = require('./Shortcuts'); +const { ShortcutsProvider, useShortcuts, onShortcut, getKeyboardShortcutKey } = require('./Shortcuts'); const { DiscordProvider, useDiscord, EMPTY_DISCORD_TIMESTAMPS, getPlaybackDiscordActivity } = require('./Discord'); const CONSTANTS = require('./CONSTANTS'); const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender'); @@ -42,6 +42,7 @@ module.exports = { ShortcutsProvider, useShortcuts, onShortcut, + getKeyboardShortcutKey, ToastProvider, useToast, TooltipProvider, diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index fbfa46fd06..2f00e6fb93 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -11,7 +11,7 @@ const { default: useRouteFocused } = require('stremio/common/useRouteFocused'); const { useCore } = require('stremio/core'); const { useServices, useGamepad } = require('stremio/services'); const { useContentGamepadNavigation } = require('stremio/services/GamepadNavigation'); -const { useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, usePlatform, onShortcut, useDiscord, EMPTY_DISCORD_TIMESTAMPS, getPlaybackDiscordActivity } = require('stremio/common'); +const { useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, usePlatform, onShortcut, getKeyboardShortcutKey, useDiscord, EMPTY_DISCORD_TIMESTAMPS, getPlaybackDiscordActivity } = require('stremio/common'); const { default: toPath } = require('stremio-router/toPath'); const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components'); const { default: Buffering } = require('./Buffering'); @@ -673,7 +673,8 @@ const Player = () => { } const onKeyDown = (e) => { - if (e.code !== 'Space' || e.repeat) return; + const keyboardKey = getKeyboardShortcutKey(e); + if (keyboardKey !== 'Space' || e.repeat) return; if (menusOpen || e.ctrlKey || e.metaKey || e.altKey) return; longPress.current = false; @@ -685,14 +686,15 @@ const Player = () => { }; const onKeyUp = (e) => { - if (e.code !== 'Space' && e.code !== 'ArrowRight' && e.code !== 'ArrowLeft') return; + const keyboardKey = getKeyboardShortcutKey(e); + if (keyboardKey !== 'Space' && keyboardKey !== 'ArrowRight' && keyboardKey !== 'ArrowLeft') return; if (e.ctrlKey || e.metaKey || e.altKey) return; - if (e.code === 'ArrowRight' || e.code === 'ArrowLeft') { + if (keyboardKey === 'ArrowRight' || keyboardKey === 'ArrowLeft') { setSeeking(false); return; } - if (e.code === 'Space') { + if (keyboardKey === 'Space') { clearTimeout(pressTimer.current); pressTimer.current = null; if (longPress.current) {