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" ? "⇧" : "⌥";
+}