From cb504ccf5593358f27aca06c91d323d499b18a2e Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Tue, 23 Dec 2025 23:26:59 -0700 Subject: [PATCH] customize keyboard shortcuts! --- src/assets/locales/en.json | 22 +- src/backend/accounts/settings.ts | 3 + .../overlays/KeyboardCommandsEditModal.tsx | 518 ++++++++++++++++++ .../overlays/KeyboardCommandsModal.tsx | 374 ++++++++----- src/components/overlays/Modal.tsx | 7 +- .../player/internals/KeyboardEvents.tsx | 247 +++++++-- src/hooks/auth/useAuthData.ts | 8 + src/pages/parts/settings/PreferencesPart.tsx | 18 + src/setup/App.tsx | 2 + src/stores/preferences/index.tsx | 13 + src/utils/keyboardShortcuts.ts | 298 ++++++++++ 11 files changed, 1308 insertions(+), 202 deletions(-) create mode 100644 src/components/overlays/KeyboardCommandsEditModal.tsx create mode 100644 src/utils/keyboardShortcuts.ts diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 1fe42710..9bb029f1 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -255,8 +255,8 @@ "skipBackward5": "Skip backward 5 seconds", "skipBackward10": "Skip backward 10 seconds", "skipForward10": "Skip forward 10 seconds", - "skipForward1": "Skip forward 1 second (when paused)", - "skipBackward1": "Skip backward 1 second (when paused)", + "skipForward1": "Skip forward 1 second", + "skipBackward1": "Skip backward 1 second", "jumpTo0": "Jump to 0% (beginning)", "jumpTo9": "Jump to 90%", "increaseVolume": "Increase volume", @@ -277,9 +277,20 @@ }, "conditions": { "notInWatchParty": "Not in watch party", - "whenPaused": "When paused", "showsOnly": "Shows only" - } + }, + "editInSettings": "Edit keyboard commands from settings", + "clickToEdit": "Click on a key badge to edit it", + "conflict": "conflict", + "conflicts": "conflicts", + "detected": "detected", + "resetAllToDefault": "Reset All to Default", + "pressKey": "Press a key...", + "none": "None", + "save": "Save", + "cancel": "Cancel", + "saveChanges": "Save Changes", + "resetToDefault": "Reset to default" } }, "home": { @@ -1175,6 +1186,9 @@ "doubleClickToSeek": "Double tap to seek", "doubleClickToSeekDescription": "Double tap on the left or right side of the player to seek 10 seconds forward or backward.", "doubleClickToSeekLabel": "Enable double tap to seek", + "keyboardShortcuts": "Keyboard Shortcuts", + "keyboardShortcutsDescription": "Customize the keyboard shortcuts for the application. Hold ` to show this help anytime", + "keyboardShortcutsLabel": "Customize Keyboard Shortcuts", "sourceOrder": "Reordering sources", "sourceOrderDescription": "Drag and drop to reorder sources. This will determine the order in which sources are checked for the media you are trying to watch. If a source is greyed out, it means the extension is required for that source.

(The default order is best for most users)", "sourceOrderEnableLabel": "Custom source order", diff --git a/src/backend/accounts/settings.ts b/src/backend/accounts/settings.ts index c38cf17f..53fe9120 100644 --- a/src/backend/accounts/settings.ts +++ b/src/backend/accounts/settings.ts @@ -2,6 +2,7 @@ import { ofetch } from "ofetch"; import { getAuthHeaders } from "@/backend/accounts/auth"; import { AccountWithToken } from "@/stores/auth"; +import { KeyboardShortcuts } from "@/utils/keyboardShortcuts"; export interface SettingsInput { applicationLanguage?: string; @@ -36,6 +37,7 @@ export interface SettingsInput { manualSourceSelection?: boolean; enableDoubleClickToSeek?: boolean; enableAutoResumeOnPlaybackError?: boolean; + keyboardShortcuts?: KeyboardShortcuts; } export interface SettingsResponse { @@ -71,6 +73,7 @@ export interface SettingsResponse { manualSourceSelection?: boolean; enableDoubleClickToSeek?: boolean; enableAutoResumeOnPlaybackError?: boolean; + keyboardShortcuts?: KeyboardShortcuts; } export function updateSettings( diff --git a/src/components/overlays/KeyboardCommandsEditModal.tsx b/src/components/overlays/KeyboardCommandsEditModal.tsx new file mode 100644 index 00000000..3d40e4c7 --- /dev/null +++ b/src/components/overlays/KeyboardCommandsEditModal.tsx @@ -0,0 +1,518 @@ +import { ReactNode, useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { updateSettings } from "@/backend/accounts/settings"; +import { Button } from "@/components/buttons/Button"; +import { Dropdown } from "@/components/form/Dropdown"; +import { Icon, Icons } from "@/components/Icon"; +import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; +import { Heading2 } from "@/components/utils/Text"; +import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; +import { useAuthStore } from "@/stores/auth"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; +import { usePreferencesStore } from "@/stores/preferences"; +import { + DEFAULT_KEYBOARD_SHORTCUTS, + KeyboardModifier, + KeyboardShortcutConfig, + KeyboardShortcuts, + LOCKED_SHORTCUT_IDS, + ShortcutId, + findConflicts, + getKeyDisplayName, + getModifierSymbol, + isNumberKey, +} from "@/utils/keyboardShortcuts"; + +interface KeyboardShortcut { + id: ShortcutId; + config: KeyboardShortcutConfig; + description: string; + condition?: string; +} + +interface ShortcutGroup { + title: string; + shortcuts: KeyboardShortcut[]; +} + +function KeyBadge({ + config, + children, + onClick, + editing, + hasConflict, +}: { + config?: KeyboardShortcutConfig; + children: ReactNode; + onClick?: () => void; + editing?: boolean; + hasConflict?: boolean; +}) { + const modifier = config?.modifier; + + return ( + + {children} + {modifier && ( + + {getModifierSymbol(modifier)} + + )} + + ); +} + +const getShortcutGroups = ( + t: (key: string) => string, + shortcuts: KeyboardShortcuts, +): ShortcutGroup[] => { + return [ + { + title: t("global.keyboardShortcuts.groups.videoPlayback"), + shortcuts: [ + { + id: ShortcutId.SKIP_FORWARD_5, + config: shortcuts[ShortcutId.SKIP_FORWARD_5], + description: t("global.keyboardShortcuts.shortcuts.skipForward5"), + }, + { + id: ShortcutId.SKIP_BACKWARD_5, + config: shortcuts[ShortcutId.SKIP_BACKWARD_5], + description: t("global.keyboardShortcuts.shortcuts.skipBackward5"), + }, + { + id: ShortcutId.SKIP_FORWARD_10, + config: shortcuts[ShortcutId.SKIP_FORWARD_10], + description: t("global.keyboardShortcuts.shortcuts.skipForward10"), + }, + { + id: ShortcutId.SKIP_BACKWARD_10, + config: shortcuts[ShortcutId.SKIP_BACKWARD_10], + description: t("global.keyboardShortcuts.shortcuts.skipBackward10"), + }, + { + id: ShortcutId.SKIP_FORWARD_1, + config: shortcuts[ShortcutId.SKIP_FORWARD_1], + description: t("global.keyboardShortcuts.shortcuts.skipForward1"), + }, + { + id: ShortcutId.SKIP_BACKWARD_1, + config: shortcuts[ShortcutId.SKIP_BACKWARD_1], + description: t("global.keyboardShortcuts.shortcuts.skipBackward1"), + }, + { + id: ShortcutId.NEXT_EPISODE, + config: shortcuts[ShortcutId.NEXT_EPISODE], + description: t("global.keyboardShortcuts.shortcuts.nextEpisode"), + condition: t("global.keyboardShortcuts.conditions.showsOnly"), + }, + { + id: ShortcutId.PREVIOUS_EPISODE, + config: shortcuts[ShortcutId.PREVIOUS_EPISODE], + description: t("global.keyboardShortcuts.shortcuts.previousEpisode"), + condition: t("global.keyboardShortcuts.conditions.showsOnly"), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.audioVideo"), + shortcuts: [ + { + id: ShortcutId.MUTE, + config: shortcuts[ShortcutId.MUTE], + description: t("global.keyboardShortcuts.shortcuts.mute"), + }, + { + id: ShortcutId.TOGGLE_FULLSCREEN, + config: shortcuts[ShortcutId.TOGGLE_FULLSCREEN], + description: t("global.keyboardShortcuts.shortcuts.toggleFullscreen"), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.subtitlesAccessibility"), + shortcuts: [ + { + id: ShortcutId.TOGGLE_CAPTIONS, + config: shortcuts[ShortcutId.TOGGLE_CAPTIONS], + description: t("global.keyboardShortcuts.shortcuts.toggleCaptions"), + }, + { + id: ShortcutId.RANDOM_CAPTION, + config: shortcuts[ShortcutId.RANDOM_CAPTION], + description: t("global.keyboardShortcuts.shortcuts.randomCaption"), + }, + { + id: ShortcutId.SYNC_SUBTITLES_EARLIER, + config: shortcuts[ShortcutId.SYNC_SUBTITLES_EARLIER], + description: t( + "global.keyboardShortcuts.shortcuts.syncSubtitlesEarlier", + ), + }, + { + id: ShortcutId.SYNC_SUBTITLES_LATER, + config: shortcuts[ShortcutId.SYNC_SUBTITLES_LATER], + description: t( + "global.keyboardShortcuts.shortcuts.syncSubtitlesLater", + ), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.interface"), + shortcuts: [ + { + id: ShortcutId.BARREL_ROLL, + config: shortcuts[ShortcutId.BARREL_ROLL], + description: t("global.keyboardShortcuts.shortcuts.barrelRoll"), + }, + ], + }, + ]; +}; + +interface KeyboardCommandsEditModalProps { + id: string; +} + +export function KeyboardCommandsEditModal({ + id, +}: KeyboardCommandsEditModalProps) { + const { t } = useTranslation(); + const account = useAuthStore((s) => s.account); + const backendUrl = useBackendUrl(); + const { hideModal } = useOverlayStack(); + const modal = useModal(id); + const keyboardShortcuts = usePreferencesStore((s) => s.keyboardShortcuts); + const setKeyboardShortcuts = usePreferencesStore( + (s) => s.setKeyboardShortcuts, + ); + + const [editingShortcuts, setEditingShortcuts] = + useState(keyboardShortcuts); + const [editingId, setEditingId] = useState(null); + const [editingModifier, setEditingModifier] = useState( + "", + ); + const [editingKey, setEditingKey] = useState(""); + const [isCapturingKey, setIsCapturingKey] = useState(false); + + // Cancel any active editing when modal closes + useEffect(() => { + if (!modal.isShown) { + setEditingId(null); + setEditingModifier(""); + setEditingKey(""); + setIsCapturingKey(false); + } + }, [modal.isShown]); + + const shortcutGroups = getShortcutGroups(t, editingShortcuts).map( + (group) => ({ + ...group, + shortcuts: group.shortcuts.filter( + (s) => !LOCKED_SHORTCUT_IDS.includes(s.id), + ), + }), + ); + const conflicts = findConflicts(editingShortcuts); + const conflictIds = new Set(); + conflicts.forEach((conflict: { id1: string; id2: string }) => { + conflictIds.add(conflict.id1); + conflictIds.add(conflict.id2); + }); + + const modifierOptions = [ + { id: "", name: "None" }, + { id: "Shift", name: "Shift" }, + { id: "Alt", name: "Alt" }, + ]; + + const handleStartEdit = useCallback( + (shortcutId: ShortcutId) => { + const config = editingShortcuts[shortcutId]; + setEditingId(shortcutId); + setEditingModifier(config?.modifier || ""); + setEditingKey(config?.key || ""); + setIsCapturingKey(true); + }, + [editingShortcuts], + ); + + const handleCancelEdit = useCallback(() => { + setEditingId(null); + setEditingModifier(""); + setEditingKey(""); + setIsCapturingKey(false); + }, []); + + const handleKeyCapture = useCallback( + (event: KeyboardEvent) => { + if (!isCapturingKey || !editingId) return; + + // Don't capture modifier keys alone + if ( + event.key === "Shift" || + event.key === "Alt" || + event.key === "Control" || + event.key === "Meta" || + event.key === "Escape" + ) { + return; + } + + // Block number keys (0-9) - they're reserved for progress skipping + if (isNumberKey(event.key)) { + event.preventDefault(); + event.stopPropagation(); + setIsCapturingKey(false); + return; + } + + event.preventDefault(); + event.stopPropagation(); + + setEditingKey(event.key); + setIsCapturingKey(false); + }, + [isCapturingKey, editingId], + ); + + useEffect(() => { + if (isCapturingKey) { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + handleCancelEdit(); + } + }; + window.addEventListener("keydown", handleKeyCapture); + window.addEventListener("keydown", handleEscape); + return () => { + window.removeEventListener("keydown", handleKeyCapture); + window.removeEventListener("keydown", handleEscape); + }; + } + }, [isCapturingKey, handleKeyCapture, handleCancelEdit]); + + const handleSaveEdit = useCallback(() => { + if (!editingId) return; + + const newConfig: KeyboardShortcutConfig = { + modifier: editingModifier || undefined, + key: editingKey || undefined, + }; + + setEditingShortcuts((prev: KeyboardShortcuts) => ({ + ...prev, + [editingId]: newConfig, + })); + + handleCancelEdit(); + }, [editingId, editingModifier, editingKey, handleCancelEdit]); + + const handleResetShortcut = useCallback((shortcutId: ShortcutId) => { + setEditingShortcuts((prev: KeyboardShortcuts) => ({ + ...prev, + [shortcutId]: DEFAULT_KEYBOARD_SHORTCUTS[shortcutId], + })); + }, []); + + const handleResetAll = useCallback(() => { + setEditingShortcuts(DEFAULT_KEYBOARD_SHORTCUTS); + }, []); + + const handleSave = useCallback(async () => { + setKeyboardShortcuts(editingShortcuts); + + if (account && backendUrl) { + try { + await updateSettings(backendUrl, account, { + keyboardShortcuts: editingShortcuts, + }); + } catch (error) { + console.error("Failed to save keyboard shortcuts:", error); + } + } + + hideModal(id); + }, [ + editingShortcuts, + account, + backendUrl, + setKeyboardShortcuts, + hideModal, + id, + ]); + + const handleCancel = useCallback(() => { + hideModal(id); + }, [hideModal, id]); + + return ( + + +
+
+ + {t("global.keyboardShortcuts.title")} + +

+ {t("global.keyboardShortcuts.clickToEdit")} +

+
+ +
+ {conflicts.length > 0 ? ( +

+ {conflicts.length}{" "} + {conflicts.length > 1 + ? t("global.keyboardShortcuts.conflicts") + : t("global.keyboardShortcuts.conflict")}{" "} + {t("global.keyboardShortcuts.detected")} +

+ ) : ( +
// Empty div to take up space + )} + +
+ +
+ {shortcutGroups.map((group) => ( +
+

+ {group.title} +

+
+ {group.shortcuts.map((shortcut) => { + const isEditing = editingId === shortcut.id; + const hasConflict = conflictIds.has(shortcut.id); + const config = editingShortcuts[shortcut.id]; + + return ( +
+
+ {isEditing ? ( +
+
+ opt.id === editingModifier, + ) || modifierOptions[0] + } + setSelectedItem={(item) => + setEditingModifier( + item.id as KeyboardModifier | "", + ) + } + options={modifierOptions} + className="w-32 !my-1" + /> + + {isCapturingKey + ? t("global.keyboardShortcuts.pressKey") + : editingKey + ? getKeyDisplayName(editingKey) + : t("global.keyboardShortcuts.none")} + +
+
+ + +
+
+ ) : ( + <> + handleStartEdit(shortcut.id)} + hasConflict={hasConflict} + > + {config?.key + ? getKeyDisplayName(config.key) + : t("global.keyboardShortcuts.none")} + + + {shortcut.description} + + + )} +
+
+ {shortcut.condition && !isEditing && ( + + {shortcut.condition} + + )} + {!isEditing && ( + + )} +
+
+ ); + })} +
+
+ ))} +
+ +
+ + +
+
+ + + ); +} diff --git a/src/components/overlays/KeyboardCommandsModal.tsx b/src/components/overlays/KeyboardCommandsModal.tsx index 0e096328..8e93438e 100644 --- a/src/components/overlays/KeyboardCommandsModal.tsx +++ b/src/components/overlays/KeyboardCommandsModal.tsx @@ -1,13 +1,23 @@ import { ReactNode } from "react"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; import { Modal, ModalCard } from "@/components/overlays/Modal"; import { Heading2 } from "@/components/utils/Text"; +import { usePreferencesStore } from "@/stores/preferences"; +import { + DEFAULT_KEYBOARD_SHORTCUTS, + KeyboardShortcutConfig, + ShortcutId, + getKeyDisplayName, + getModifierSymbol, +} from "@/utils/keyboardShortcuts"; interface KeyboardShortcut { key: string; description: string; condition?: string; + config?: KeyboardShortcutConfig; } interface ShortcutGroup { @@ -15,153 +25,198 @@ interface ShortcutGroup { shortcuts: KeyboardShortcut[]; } -const getShortcutGroups = (t: (key: string) => string): ShortcutGroup[] => [ - { - title: t("global.keyboardShortcuts.groups.videoPlayback"), - shortcuts: [ - { - key: "Space", - description: t("global.keyboardShortcuts.shortcuts.playPause"), - }, - { - key: "K", - description: t("global.keyboardShortcuts.shortcuts.playPauseAlt"), - }, - { - key: "→", - description: t("global.keyboardShortcuts.shortcuts.skipForward5"), - }, - { - key: "←", - description: t("global.keyboardShortcuts.shortcuts.skipBackward5"), - }, - { - key: "J", - description: t("global.keyboardShortcuts.shortcuts.skipBackward10"), - }, - { - key: "L", - description: t("global.keyboardShortcuts.shortcuts.skipForward10"), - }, - { - key: ".", - description: t("global.keyboardShortcuts.shortcuts.skipForward1"), - condition: t("global.keyboardShortcuts.conditions.whenPaused"), - }, - { - key: ",", - description: t("global.keyboardShortcuts.shortcuts.skipBackward1"), - condition: t("global.keyboardShortcuts.conditions.whenPaused"), - }, - { - key: "P", - description: t("global.keyboardShortcuts.shortcuts.nextEpisode"), - condition: t("global.keyboardShortcuts.conditions.showsOnly"), - }, - { - key: "O", - description: t("global.keyboardShortcuts.shortcuts.previousEpisode"), - condition: t("global.keyboardShortcuts.conditions.showsOnly"), - }, - ], - }, - { - title: t("global.keyboardShortcuts.groups.jumpToPosition"), - shortcuts: [ - { - key: "0", - description: t("global.keyboardShortcuts.shortcuts.jumpTo0"), - }, - { - key: "9", - description: t("global.keyboardShortcuts.shortcuts.jumpTo9"), - }, - ], - }, - { - title: t("global.keyboardShortcuts.groups.audioVideo"), - shortcuts: [ - { - key: "↑", - description: t("global.keyboardShortcuts.shortcuts.increaseVolume"), - }, - { - key: "↓", - description: t("global.keyboardShortcuts.shortcuts.decreaseVolume"), - }, - { key: "M", description: t("global.keyboardShortcuts.shortcuts.mute") }, - { - key: ">/", - description: t("global.keyboardShortcuts.shortcuts.changeSpeed"), - condition: t("global.keyboardShortcuts.conditions.notInWatchParty"), - }, - { - key: "F", - description: t("global.keyboardShortcuts.shortcuts.toggleFullscreen"), - }, - ], - }, - { - title: t("global.keyboardShortcuts.groups.subtitlesAccessibility"), - shortcuts: [ - { - key: "C", - description: t("global.keyboardShortcuts.shortcuts.toggleCaptions"), - }, - { - key: "Shift+C", - description: t("global.keyboardShortcuts.shortcuts.randomCaption"), - }, - { - key: "[", - description: t( - "global.keyboardShortcuts.shortcuts.syncSubtitlesEarlier", - ), - }, - { - key: "]", - description: t("global.keyboardShortcuts.shortcuts.syncSubtitlesLater"), - }, - ], - }, - { - title: t("global.keyboardShortcuts.groups.interface"), - shortcuts: [ - { - key: "R", - description: t("global.keyboardShortcuts.shortcuts.barrelRoll"), - }, - { - key: "Escape", - description: t("global.keyboardShortcuts.shortcuts.closeOverlay"), - }, - { - key: "Shift", - description: t("global.keyboardShortcuts.shortcuts.copyLinkWithTime"), - }, - { - key: "Shift", - description: t("global.keyboardShortcuts.shortcuts.widescreenMode"), - }, - ], - }, -]; +function KeyBadge({ + config, + children, +}: { + config?: KeyboardShortcutConfig; + children: ReactNode; +}) { + const modifier = config?.modifier; -function KeyBadge({ children }: { children: ReactNode }) { return ( - + {children} + {modifier && ( + + {getModifierSymbol(modifier)} + + )} ); } +const getShortcutGroups = ( + t: (key: string) => string, + shortcuts: Record, +): ShortcutGroup[] => { + // Merge user shortcuts with defaults (user shortcuts take precedence) + const mergedShortcuts = { + ...DEFAULT_KEYBOARD_SHORTCUTS, + ...shortcuts, + }; + + const getDisplayKey = (shortcutId: ShortcutId): string => { + const config = mergedShortcuts[shortcutId]; + if (!config?.key) return ""; + return getKeyDisplayName(config.key); + }; + + const getConfig = ( + shortcutId: ShortcutId, + ): KeyboardShortcutConfig | undefined => { + return mergedShortcuts[shortcutId]; + }; + + return [ + { + title: t("global.keyboardShortcuts.groups.videoPlayback"), + shortcuts: [ + { + key: "Space", + description: t("global.keyboardShortcuts.shortcuts.playPause"), + }, + { + key: "K", + description: t("global.keyboardShortcuts.shortcuts.playPauseAlt"), + }, + { + key: getDisplayKey(ShortcutId.SKIP_FORWARD_5) || "→", + description: t("global.keyboardShortcuts.shortcuts.skipForward5"), + config: getConfig(ShortcutId.SKIP_FORWARD_5), + }, + { + key: getDisplayKey(ShortcutId.SKIP_BACKWARD_5) || "←", + description: t("global.keyboardShortcuts.shortcuts.skipBackward5"), + config: getConfig(ShortcutId.SKIP_BACKWARD_5), + }, + { + key: getDisplayKey(ShortcutId.SKIP_BACKWARD_10) || "J", + description: t("global.keyboardShortcuts.shortcuts.skipBackward10"), + config: getConfig(ShortcutId.SKIP_BACKWARD_10), + }, + { + key: getDisplayKey(ShortcutId.SKIP_FORWARD_10) || "L", + description: t("global.keyboardShortcuts.shortcuts.skipForward10"), + config: getConfig(ShortcutId.SKIP_FORWARD_10), + }, + { + key: getDisplayKey(ShortcutId.SKIP_FORWARD_1) || ".", + description: t("global.keyboardShortcuts.shortcuts.skipForward1"), + config: getConfig(ShortcutId.SKIP_FORWARD_1), + }, + { + key: getDisplayKey(ShortcutId.SKIP_BACKWARD_1) || ",", + description: t("global.keyboardShortcuts.shortcuts.skipBackward1"), + config: getConfig(ShortcutId.SKIP_BACKWARD_1), + }, + { + key: getDisplayKey(ShortcutId.NEXT_EPISODE) || "P", + description: t("global.keyboardShortcuts.shortcuts.nextEpisode"), + condition: t("global.keyboardShortcuts.conditions.showsOnly"), + config: getConfig(ShortcutId.NEXT_EPISODE), + }, + { + key: getDisplayKey(ShortcutId.PREVIOUS_EPISODE) || "O", + description: t("global.keyboardShortcuts.shortcuts.previousEpisode"), + condition: t("global.keyboardShortcuts.conditions.showsOnly"), + config: getConfig(ShortcutId.PREVIOUS_EPISODE), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.jumpToPosition"), + shortcuts: [ + { + key: getDisplayKey(ShortcutId.JUMP_TO_0) || "0", + description: t("global.keyboardShortcuts.shortcuts.jumpTo0"), + config: getConfig(ShortcutId.JUMP_TO_0), + }, + { + key: getDisplayKey(ShortcutId.JUMP_TO_9) || "9", + description: t("global.keyboardShortcuts.shortcuts.jumpTo9"), + config: getConfig(ShortcutId.JUMP_TO_9), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.audioVideo"), + shortcuts: [ + { + key: "↑", + description: t("global.keyboardShortcuts.shortcuts.increaseVolume"), + }, + { + key: "↓", + description: t("global.keyboardShortcuts.shortcuts.decreaseVolume"), + }, + { + key: getDisplayKey(ShortcutId.MUTE) || "M", + description: t("global.keyboardShortcuts.shortcuts.mute"), + config: getConfig(ShortcutId.MUTE), + }, + { + key: getDisplayKey(ShortcutId.TOGGLE_FULLSCREEN) || "F", + description: t("global.keyboardShortcuts.shortcuts.toggleFullscreen"), + config: getConfig(ShortcutId.TOGGLE_FULLSCREEN), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.subtitlesAccessibility"), + shortcuts: [ + { + key: getDisplayKey(ShortcutId.TOGGLE_CAPTIONS) || "C", + description: t("global.keyboardShortcuts.shortcuts.toggleCaptions"), + config: getConfig(ShortcutId.TOGGLE_CAPTIONS), + }, + { + key: getDisplayKey(ShortcutId.RANDOM_CAPTION) || "Shift+C", + description: t("global.keyboardShortcuts.shortcuts.randomCaption"), + config: getConfig(ShortcutId.RANDOM_CAPTION), + }, + { + key: getDisplayKey(ShortcutId.SYNC_SUBTITLES_EARLIER) || "[", + description: t( + "global.keyboardShortcuts.shortcuts.syncSubtitlesEarlier", + ), + config: getConfig(ShortcutId.SYNC_SUBTITLES_EARLIER), + }, + { + key: getDisplayKey(ShortcutId.SYNC_SUBTITLES_LATER) || "]", + description: t( + "global.keyboardShortcuts.shortcuts.syncSubtitlesLater", + ), + config: getConfig(ShortcutId.SYNC_SUBTITLES_LATER), + }, + ], + }, + { + title: t("global.keyboardShortcuts.groups.interface"), + shortcuts: [ + { + key: getDisplayKey(ShortcutId.BARREL_ROLL) || "R", + description: t("global.keyboardShortcuts.shortcuts.barrelRoll"), + config: getConfig(ShortcutId.BARREL_ROLL), + }, + { + key: "Escape", + description: t("global.keyboardShortcuts.shortcuts.closeOverlay"), + }, + ], + }, + ]; +}; + interface KeyboardCommandsModalProps { id: string; } export function KeyboardCommandsModal({ id }: KeyboardCommandsModalProps) { const { t } = useTranslation(); - const shortcutGroups = getShortcutGroups(t); + const navigate = useNavigate(); + const keyboardShortcuts = usePreferencesStore((s) => s.keyboardShortcuts); + const shortcutGroups = getShortcutGroups(t, keyboardShortcuts); return ( @@ -178,12 +233,23 @@ export function KeyboardCommandsModal({ id }: KeyboardCommandsModalProps) { return ( <> {before} - ` + ` {after} ); })()}

+

+ +

@@ -193,24 +259,28 @@ export function KeyboardCommandsModal({ id }: KeyboardCommandsModalProps) { {group.title}
- {group.shortcuts.map((shortcut) => ( -
-
- {shortcut.key} - - {shortcut.description} - + {group.shortcuts + .filter((shortcut) => shortcut.key) // Only show shortcuts that have a key configured + .map((shortcut) => ( +
+
+ + {shortcut.key} + + + {shortcut.description} + +
+ {shortcut.condition && ( + + {shortcut.condition} + + )}
- {shortcut.condition && ( - - {shortcut.condition} - - )} -
- ))} + ))}
))} diff --git a/src/components/overlays/Modal.tsx b/src/components/overlays/Modal.tsx index 9a6c2e24..fea9fe41 100644 --- a/src/components/overlays/Modal.tsx +++ b/src/components/overlays/Modal.tsx @@ -21,9 +21,12 @@ export function useModal(id: string) { }; } -export function ModalCard(props: { children?: ReactNode }) { +export function ModalCard(props: { + children?: ReactNode; + className?: ReactNode; +}) { return ( -
+
{props.children}
diff --git a/src/components/player/internals/KeyboardEvents.tsx b/src/components/player/internals/KeyboardEvents.tsx index 372d183e..ce9a6fcc 100644 --- a/src/components/player/internals/KeyboardEvents.tsx +++ b/src/components/player/internals/KeyboardEvents.tsx @@ -13,6 +13,11 @@ import { useProgressStore } from "@/stores/progress"; import { useSubtitleStore } from "@/stores/subtitles"; import { useEmpheralVolumeStore } from "@/stores/volume"; import { useWatchPartyStore } from "@/stores/watchParty"; +import { + LOCKED_SHORTCUTS, + ShortcutId, + matchesShortcut, +} from "@/utils/keyboardShortcuts"; export function KeyboardEvents() { const router = useOverlayRouter(""); @@ -44,6 +49,7 @@ export function KeyboardEvents() { (s) => s.setShowDelayIndicator, ); const enableHoldToBoost = usePreferencesStore((s) => s.enableHoldToBoost); + const keyboardShortcuts = usePreferencesStore((s) => s.keyboardShortcuts); const [isRolling, setIsRolling] = useState(false); const volumeDebounce = useRef | undefined>(); @@ -288,6 +294,7 @@ export function KeyboardEvents() { enableHoldToBoost, navigateToNextEpisode, navigateToPreviousEpisode, + keyboardShortcuts, }); useEffect(() => { @@ -321,6 +328,7 @@ export function KeyboardEvents() { enableHoldToBoost, navigateToNextEpisode, navigateToPreviousEpisode, + keyboardShortcuts, }; }, [ setShowVolume, @@ -347,6 +355,7 @@ export function KeyboardEvents() { enableHoldToBoost, navigateToNextEpisode, navigateToPreviousEpisode, + keyboardShortcuts, ]); useEffect(() => { @@ -357,7 +366,7 @@ export function KeyboardEvents() { const k = evt.key; const keyL = evt.key.toLowerCase(); - // Volume + // Volume (locked shortcuts - ArrowUp/ArrowDown always work) if (["ArrowUp", "ArrowDown", "m", "M"].includes(k)) { dataRef.current.setShowVolume(true); dataRef.current.setCurrentOverlay("volume"); @@ -368,17 +377,22 @@ export function KeyboardEvents() { dataRef.current.setCurrentOverlay(null); }, 3e3); } - if (k === "ArrowUp") + if (k === LOCKED_SHORTCUTS.ARROW_UP) dataRef.current.setVolume( (dataRef.current.mediaPlaying?.volume || 0) + 0.15, ); - if (k === "ArrowDown") + if (k === LOCKED_SHORTCUTS.ARROW_DOWN) dataRef.current.setVolume( (dataRef.current.mediaPlaying?.volume || 0) - 0.15, ); - if (keyL === "m") dataRef.current.toggleMute(); + // Mute - check customizable shortcut + if ( + matchesShortcut(evt, dataRef.current.keyboardShortcuts[ShortcutId.MUTE]) + ) { + dataRef.current.toggleMute(); + } - // Video playback speed - disabled in watch party + // Video playback speed - disabled in watch party (hardcoded, not customizable) if ((k === ">" || k === "<") && !dataRef.current.isInWatchParty) { const options = [0.25, 0.5, 1, 1.5, 2]; let idx = options.indexOf(dataRef.current.mediaPlaying?.playbackRate); @@ -389,8 +403,9 @@ export function KeyboardEvents() { } // Handle spacebar press for play/pause and hold for 2x speed - disabled in watch party or when hold to boost is disabled + // Space is locked, always check it if ( - k === " " && + k === LOCKED_SHORTCUTS.PLAY_PAUSE_SPACE && !dataRef.current.isInWatchParty && dataRef.current.enableHoldToBoost ) { @@ -455,8 +470,9 @@ export function KeyboardEvents() { } // Handle spacebar press for simple play/pause when hold to boost is disabled or in watch party mode + // Space is locked, always check it if ( - k === " " && + k === LOCKED_SHORTCUTS.PLAY_PAUSE_SPACE && (!dataRef.current.enableHoldToBoost || dataRef.current.isInWatchParty) ) { // Skip if it's a repeated event @@ -480,38 +496,130 @@ export function KeyboardEvents() { dataRef.current.display?.[action](); } - // Video progress - if (k === "ArrowRight") - dataRef.current.display?.setTime(dataRef.current.time + 5); - if (k === "ArrowLeft") - dataRef.current.display?.setTime(dataRef.current.time - 5); - if (keyL === "j") - dataRef.current.display?.setTime(dataRef.current.time - 10); - if (keyL === "l") - dataRef.current.display?.setTime(dataRef.current.time + 10); - if (k === "." && dataRef.current.mediaPlaying?.isPaused) - dataRef.current.display?.setTime(dataRef.current.time + 1); - if (k === "," && dataRef.current.mediaPlaying?.isPaused) - dataRef.current.display?.setTime(dataRef.current.time - 1); + // Video progress - handle skip shortcuts + // Skip repeated key events to prevent multiple skips + if (evt.repeat) return; - // Skip to percentage with number keys (0-9) + // Arrow keys are locked (always 5 seconds) - handle first and return + if (k === LOCKED_SHORTCUTS.ARROW_RIGHT) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time + 5); + return; + } + if (k === LOCKED_SHORTCUTS.ARROW_LEFT) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time - 5); + return; + } + + // Skip forward/backward 5 seconds - customizable (skip if set to arrow keys) + const skipForward5 = + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_FORWARD_5]; + if ( + skipForward5?.key && + skipForward5.key !== LOCKED_SHORTCUTS.ARROW_RIGHT && + matchesShortcut(evt, skipForward5) + ) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time + 5); + return; + } + const skipBackward5 = + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_BACKWARD_5]; + if ( + skipBackward5?.key && + skipBackward5.key !== LOCKED_SHORTCUTS.ARROW_LEFT && + matchesShortcut(evt, skipBackward5) + ) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time - 5); + return; + } + + // Skip forward/backward 10 seconds - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_FORWARD_10], + ) + ) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time + 10); + return; + } + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_BACKWARD_10], + ) + ) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time - 10); + return; + } + + // Skip forward/backward 1 second - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_FORWARD_1], + ) + ) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time + 1); + return; + } + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.SKIP_BACKWARD_1], + ) + ) { + evt.preventDefault(); + dataRef.current.display?.setTime(dataRef.current.time - 1); + return; + } + + // Skip to percentage with number keys (0-9) - locked, always use number keys + // Number keys are reserved for progress skipping, so handle them before customizable shortcuts if ( /^[0-9]$/.test(k) && dataRef.current.duration > 0 && !evt.ctrlKey && - !evt.metaKey + !evt.metaKey && + !evt.shiftKey && + !evt.altKey ) { - const percentage = parseInt(k, 10) * 10; // 0 = 0%, 1 = 10%, 2 = 20%, ..., 9 = 90% - const targetTime = (dataRef.current.duration * percentage) / 100; - dataRef.current.display?.setTime(targetTime); + evt.preventDefault(); + if (k === "0") { + dataRef.current.display?.setTime(0); + } else if (k === "9") { + const targetTime = (dataRef.current.duration * 90) / 100; + dataRef.current.display?.setTime(targetTime); + } else { + // 1-8 for 10%-80% + const percentage = parseInt(k, 10) * 10; + const targetTime = (dataRef.current.duration * percentage) / 100; + dataRef.current.display?.setTime(targetTime); + } + return; } - // Utils - if (keyL === "f") dataRef.current.display?.toggleFullscreen(); + // Utils - Fullscreen is customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.TOGGLE_FULLSCREEN], + ) + ) { + dataRef.current.display?.toggleFullscreen(); + } - // Remove duplicate spacebar handler that was conflicting - // with our improved implementation - if (keyL === "k" && !dataRef.current.isSpaceHeldRef.current) { + // K key for play/pause - locked shortcut + if ( + keyL === LOCKED_SHORTCUTS.PLAY_PAUSE_K.toLowerCase() && + !dataRef.current.isSpaceHeldRef.current + ) { if ( evt.target && (evt.target as HTMLInputElement).nodeName === "BUTTON" @@ -522,24 +630,55 @@ export function KeyboardEvents() { const action = dataRef.current.mediaPlaying.isPaused ? "play" : "pause"; dataRef.current.display?.[action](); } - if (k === "Escape") dataRef.current.router.close(); + // Escape is locked + if (k === LOCKED_SHORTCUTS.ESCAPE) dataRef.current.router.close(); - // Episode navigation (shows only) - if (keyL === "p") dataRef.current.navigateToNextEpisode(); - if (keyL === "o") dataRef.current.navigateToPreviousEpisode(); + // Episode navigation (shows only) - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.NEXT_EPISODE], + ) + ) { + dataRef.current.navigateToNextEpisode(); + } + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.PREVIOUS_EPISODE], + ) + ) { + dataRef.current.navigateToPreviousEpisode(); + } - // captions - if (keyL === "c" && !evt.shiftKey) + // Captions - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.TOGGLE_CAPTIONS], + ) + ) { dataRef.current.toggleLastUsed().catch(() => {}); // ignore errors - // Random caption selection (Shift+C) - if (k === "C" && evt.shiftKey) { + } + // Random caption selection - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.RANDOM_CAPTION], + ) + ) { dataRef.current .selectRandomCaptionFromLastUsedLanguage() .catch(() => {}); // ignore errors } - // Do a barrell roll! - if (keyL === "r") { + // Barrel roll - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.BARREL_ROLL], + ) + ) { if (dataRef.current.isRolling || evt.ctrlKey || evt.metaKey) return; dataRef.current.setIsRolling(true); @@ -553,10 +692,30 @@ export function KeyboardEvents() { }, 1e3); } - // Subtitle sync - if (k === "[" || k === "]") { - const change = k === "[" ? -0.5 : 0.5; - dataRef.current.setDelay(dataRef.current.delay + change); + // Subtitle sync - customizable + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.SYNC_SUBTITLES_EARLIER], + ) + ) { + dataRef.current.setDelay(dataRef.current.delay - 0.5); + dataRef.current.setShowDelayIndicator(true); + dataRef.current.setCurrentOverlay("subtitle"); + + if (subtitleDebounce.current) clearTimeout(subtitleDebounce.current); + subtitleDebounce.current = setTimeout(() => { + dataRef.current.setShowDelayIndicator(false); + dataRef.current.setCurrentOverlay(null); + }, 3000); + } + if ( + matchesShortcut( + evt, + dataRef.current.keyboardShortcuts[ShortcutId.SYNC_SUBTITLES_LATER], + ) + ) { + dataRef.current.setDelay(dataRef.current.delay + 0.5); dataRef.current.setShowDelayIndicator(true); dataRef.current.setCurrentOverlay("subtitle"); diff --git a/src/hooks/auth/useAuthData.ts b/src/hooks/auth/useAuthData.ts index 3582baf2..0de5e210 100644 --- a/src/hooks/auth/useAuthData.ts +++ b/src/hooks/auth/useAuthData.ts @@ -91,6 +91,9 @@ export function useAuthData() { const setEnableAutoResumeOnPlaybackError = usePreferencesStore( (s) => s.setEnableAutoResumeOnPlaybackError, ); + const setKeyboardShortcuts = usePreferencesStore( + (s) => s.setKeyboardShortcuts, + ); const login = useCallback( async ( @@ -275,6 +278,10 @@ export function useAuthData() { settings.enableAutoResumeOnPlaybackError, ); } + + if (settings.keyboardShortcuts !== undefined) { + setKeyboardShortcuts(settings.keyboardShortcuts); + } }, [ replaceBookmarks, @@ -311,6 +318,7 @@ export function useAuthData() { setManualSourceSelection, setEnableDoubleClickToSeek, setEnableAutoResumeOnPlaybackError, + setKeyboardShortcuts, ], ); diff --git a/src/pages/parts/settings/PreferencesPart.tsx b/src/pages/parts/settings/PreferencesPart.tsx index 150ee1f9..17e419cb 100644 --- a/src/pages/parts/settings/PreferencesPart.tsx +++ b/src/pages/parts/settings/PreferencesPart.tsx @@ -11,6 +11,7 @@ import { Dropdown } from "@/components/form/Dropdown"; import { SortableListWithToggles } from "@/components/form/SortableListWithToggles"; import { Heading1 } from "@/components/utils/Text"; import { appLanguageOptions } from "@/setup/i18n"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; import { isAutoplayAllowed } from "@/utils/autoplay"; import { getLocaleInfo, sortLangCodes } from "@/utils/language"; @@ -43,6 +44,7 @@ export function PreferencesPart(props: { setEnableAutoResumeOnPlaybackError: (v: boolean) => void; }) { const { t } = useTranslation(); + const { showModal } = useOverlayStack(); const sorted = sortLangCodes(appLanguageOptions.map((item) => item.code)); const allowAutoplay = isAutoplayAllowed(); @@ -246,6 +248,22 @@ export function PreferencesPart(props: {

+ + {/* Keyboard Shortcuts Preference */} +
+

+ {t("settings.preferences.keyboardShortcuts")} +

+

+ {t("settings.preferences.keyboardShortcutsDescription")} +

+
+
{/* Column */} diff --git a/src/setup/App.tsx b/src/setup/App.tsx index ff6bf327..6e43169d 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -12,6 +12,7 @@ import { import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta"; import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb"; import { DetailsModal } from "@/components/overlays/detailsModal"; +import { KeyboardCommandsEditModal } from "@/components/overlays/KeyboardCommandsEditModal"; import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal"; import { NotificationModal } from "@/components/overlays/notificationsModal"; import { SupportInfoModal } from "@/components/overlays/SupportInfoModal"; @@ -127,6 +128,7 @@ function App() { + diff --git a/src/stores/preferences/index.tsx b/src/stores/preferences/index.tsx index 60b72d0d..9a16bd35 100644 --- a/src/stores/preferences/index.tsx +++ b/src/stores/preferences/index.tsx @@ -2,6 +2,11 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; +import { + DEFAULT_KEYBOARD_SHORTCUTS, + KeyboardShortcuts, +} from "@/utils/keyboardShortcuts"; + export interface PreferencesStore { enableThumbnails: boolean; enableAutoplay: boolean; @@ -31,6 +36,7 @@ export interface PreferencesStore { manualSourceSelection: boolean; enableDoubleClickToSeek: boolean; enableAutoResumeOnPlaybackError: boolean; + keyboardShortcuts: KeyboardShortcuts; setEnableThumbnails(v: boolean): void; setEnableAutoplay(v: boolean): void; @@ -60,6 +66,7 @@ export interface PreferencesStore { setManualSourceSelection(v: boolean): void; setEnableDoubleClickToSeek(v: boolean): void; setEnableAutoResumeOnPlaybackError(v: boolean): void; + setKeyboardShortcuts(v: KeyboardShortcuts): void; } export const usePreferencesStore = create( @@ -93,6 +100,7 @@ export const usePreferencesStore = create( manualSourceSelection: false, enableDoubleClickToSeek: false, enableAutoResumeOnPlaybackError: true, + keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, setEnableThumbnails(v) { set((s) => { s.enableThumbnails = v; @@ -238,6 +246,11 @@ export const usePreferencesStore = create( s.enableAutoResumeOnPlaybackError = v; }); }, + setKeyboardShortcuts(v) { + set((s) => { + s.keyboardShortcuts = v; + }); + }, })), { name: "__MW::preferences", diff --git a/src/utils/keyboardShortcuts.ts b/src/utils/keyboardShortcuts.ts new file mode 100644 index 00000000..543bd7c3 --- /dev/null +++ b/src/utils/keyboardShortcuts.ts @@ -0,0 +1,298 @@ +/** + * Keyboard shortcuts configuration and utilities + */ + +export type KeyboardModifier = "Shift" | "Alt"; + +export interface KeyboardShortcutConfig { + modifier?: KeyboardModifier; + key?: string; +} + +export type KeyboardShortcuts = Record; + +/** + * Shortcut IDs for customizable shortcuts + */ +export enum ShortcutId { + // Video playback + SKIP_FORWARD_5 = "skipForward5", + SKIP_BACKWARD_5 = "skipBackward5", + SKIP_FORWARD_10 = "skipForward10", + SKIP_BACKWARD_10 = "skipBackward10", + SKIP_FORWARD_1 = "skipForward1", + SKIP_BACKWARD_1 = "skipBackward1", + NEXT_EPISODE = "nextEpisode", + PREVIOUS_EPISODE = "previousEpisode", + + // Jump to position + JUMP_TO_0 = "jumpTo0", + JUMP_TO_9 = "jumpTo9", + + // Audio/Video + INCREASE_VOLUME = "increaseVolume", + DECREASE_VOLUME = "decreaseVolume", + MUTE = "mute", + TOGGLE_FULLSCREEN = "toggleFullscreen", + + // Subtitles/Accessibility + TOGGLE_CAPTIONS = "toggleCaptions", + RANDOM_CAPTION = "randomCaption", + SYNC_SUBTITLES_EARLIER = "syncSubtitlesEarlier", + SYNC_SUBTITLES_LATER = "syncSubtitlesLater", + + // Interface + BARREL_ROLL = "barrelRoll", +} + +/** + * Default keyboard shortcuts configuration + */ +export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { + [ShortcutId.SKIP_FORWARD_5]: { key: "ArrowRight" }, + [ShortcutId.SKIP_BACKWARD_5]: { key: "ArrowLeft" }, + [ShortcutId.SKIP_FORWARD_10]: { key: "L" }, + [ShortcutId.SKIP_BACKWARD_10]: { key: "J" }, + [ShortcutId.SKIP_FORWARD_1]: { key: "." }, + [ShortcutId.SKIP_BACKWARD_1]: { key: "," }, + [ShortcutId.NEXT_EPISODE]: { key: "P" }, + [ShortcutId.PREVIOUS_EPISODE]: { key: "O" }, + [ShortcutId.JUMP_TO_0]: { key: "0" }, + [ShortcutId.JUMP_TO_9]: { key: "9" }, + [ShortcutId.INCREASE_VOLUME]: { key: "ArrowUp" }, + [ShortcutId.DECREASE_VOLUME]: { key: "ArrowDown" }, + [ShortcutId.MUTE]: { key: "M" }, + [ShortcutId.TOGGLE_FULLSCREEN]: { key: "F" }, + [ShortcutId.TOGGLE_CAPTIONS]: { key: "C" }, + [ShortcutId.RANDOM_CAPTION]: { modifier: "Shift", key: "C" }, + [ShortcutId.SYNC_SUBTITLES_EARLIER]: { key: "[" }, + [ShortcutId.SYNC_SUBTITLES_LATER]: { key: "]" }, + [ShortcutId.BARREL_ROLL]: { key: "R" }, +}; + +/** + * Locked shortcuts that cannot be customized + */ +export const LOCKED_SHORTCUTS = { + PLAY_PAUSE_SPACE: " ", + PLAY_PAUSE_K: "K", + MODAL_HOTKEY: "`", + ARROW_UP: "ArrowUp", + ARROW_DOWN: "ArrowDown", + ARROW_LEFT: "ArrowLeft", + ARROW_RIGHT: "ArrowRight", + ESCAPE: "Escape", + JUMP_TO_0: "0", + JUMP_TO_9: "9", +} as const; + +/** + * Locked shortcut IDs that cannot be customized + */ +export const LOCKED_SHORTCUT_IDS: string[] = [ + "playPause", + "playPauseAlt", + "skipForward5", + "skipBackward5", + "increaseVolume", + "decreaseVolume", + "modalHotkey", + "closeOverlay", + "jumpTo0", + "jumpTo9", +]; + +/** + * Check if a key is a number key (0-9) + */ +export function isNumberKey(key: string): boolean { + return /^[0-9]$/.test(key); +} + +/** + * Key equivalence map for bidirectional mapping + * Maps keys that should be treated as equivalent (e.g., 1 and !) + */ +export const KEY_EQUIVALENCE_MAP: Record = { + // Number keys and their shift equivalents + "1": "!", + "!": "1", + "2": "@", + "@": "2", + "3": "#", + "#": "3", + "4": "$", + $: "4", + "5": "%", + "%": "5", + "6": "^", + "^": "6", + "7": "&", + "&": "7", + "8": "*", + "*": "8", + "9": "(", + "(": "9", + "0": ")", + ")": "0", + + // Other symbol pairs + "-": "_", + _: "-", + "=": "+", + "+": "=", + "[": "{", + "{": "[", + "]": "}", + "}": "]", + "\\": "|", + "|": "\\", + ";": ":", + ":": ";", + "'": '"', + '"': "'", + ",": "<", + "<": ",", + ".": ">", + ">": ".", + "/": "?", + "?": "/", + "`": "~", + "~": "`", +}; + +/** + * Get equivalent keys for a given key + */ +export function getEquivalentKeys(key: string): string[] { + const equivalent = KEY_EQUIVALENCE_MAP[key]; + if (equivalent) { + return [key, equivalent]; + } + return [key]; +} + +/** + * Normalize a key for comparison (handles case-insensitive matching) + */ +export function normalizeKey(key: string): string { + // For letter keys, use uppercase for consistency + if (/^[a-z]$/i.test(key)) { + return key.toUpperCase(); + } + return key; +} + +/** + * Check if two shortcut configs conflict + */ +export function checkShortcutConflict( + config1: KeyboardShortcutConfig | undefined, + config2: KeyboardShortcutConfig | undefined, +): boolean { + if (!config1 || !config2 || !config1.key || !config2.key) { + return false; + } + + // Check if modifiers match + if (config1.modifier !== config2.modifier) { + return false; + } + + // Check if keys match directly or are equivalent + const key1 = normalizeKey(config1.key); + const key2 = normalizeKey(config2.key); + + if (key1 === key2) { + return true; + } + + // Check equivalence + const equiv1 = getEquivalentKeys(key1); + const equiv2 = getEquivalentKeys(key2); + + return equiv1.some((k1) => equiv2.includes(k1)); +} + +/** + * Find all conflicts in a shortcuts configuration + */ +export function findConflicts( + shortcuts: KeyboardShortcuts, +): Array<{ id1: string; id2: string }> { + const conflicts: Array<{ id1: string; id2: string }> = []; + const ids = Object.keys(shortcuts); + + for (let i = 0; i < ids.length; i += 1) { + for (let j = i + 1; j < ids.length; j += 1) { + const id1 = ids[i]; + const id2 = ids[j]; + const config1 = shortcuts[id1]; + const config2 = shortcuts[id2]; + + if (checkShortcutConflict(config1, config2)) { + conflicts.push({ id1, id2 }); + } + } + } + + return conflicts; +} + +/** + * Check if a keyboard event matches a shortcut configuration + */ +export function matchesShortcut( + event: KeyboardEvent, + config: KeyboardShortcutConfig | undefined, +): boolean { + if (!config || !config.key) { + return false; + } + + const eventKey = normalizeKey(event.key); + const configKey = normalizeKey(config.key); + + // Check modifier match + if (config.modifier === "Shift" && !event.shiftKey) { + return false; + } + if (config.modifier === "Alt" && !event.altKey) { + return false; + } + // If no modifier specified, ensure no modifier is pressed (except ctrl/meta which we ignore) + if (!config.modifier && (event.shiftKey || event.altKey)) { + return false; + } + + // Check key match (direct or equivalent) + if (eventKey === configKey) { + return true; + } + + // Check equivalence + const equivKeys = getEquivalentKeys(configKey); + return equivKeys.includes(eventKey); +} + +/** + * Get display name for a key + */ +export function getKeyDisplayName(key: string): string { + const displayNames: Record = { + ArrowUp: "↑", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + " ": "Space", + }; + + return displayNames[key] || key; +} + +/** + * Get display symbol for a modifier + */ +export function getModifierSymbol(modifier: KeyboardModifier): string { + return modifier === "Shift" ? "⇧" : "⌥"; +}