mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-11 17:55:33 +00:00
customize keyboard shortcuts!
This commit is contained in:
parent
71852409c2
commit
cb504ccf55
11 changed files with 1308 additions and 202 deletions
|
|
@ -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 <bold>extension</bold> is required for that source. <br><br> <strong>(The default order is best for most users)</strong>",
|
||||
"sourceOrderEnableLabel": "Custom source order",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
518
src/components/overlays/KeyboardCommandsEditModal.tsx
Normal file
518
src/components/overlays/KeyboardCommandsEditModal.tsx
Normal file
|
|
@ -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 (
|
||||
<kbd
|
||||
className={`
|
||||
relative inline-flex items-center justify-center min-w-[2rem] h-8 px-2 text-sm font-mono bg-gray-800 text-gray-200 rounded border shadow-sm
|
||||
${onClick ? "cursor-pointer hover:bg-gray-700" : ""}
|
||||
${editing ? "ring-2 ring-blue-500" : ""}
|
||||
${hasConflict ? "border-red-500 bg-red-900/20" : "border-gray-600"}
|
||||
`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
{modifier && (
|
||||
<span className="absolute -top-1 -right-1 text-xs bg-blue-600 text-white rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{getModifierSymbol(modifier)}
|
||||
</span>
|
||||
)}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
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>(keyboardShortcuts);
|
||||
const [editingId, setEditingId] = useState<ShortcutId | null>(null);
|
||||
const [editingModifier, setEditingModifier] = useState<KeyboardModifier | "">(
|
||||
"",
|
||||
);
|
||||
const [editingKey, setEditingKey] = useState<string>("");
|
||||
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<string>();
|
||||
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 (
|
||||
<Modal id={id}>
|
||||
<ModalCard className="!max-w-2xl">
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<Heading2 className="!mt-0 !mb-2">
|
||||
{t("global.keyboardShortcuts.title")}
|
||||
</Heading2>
|
||||
<p className="text-type-secondary text-sm">
|
||||
{t("global.keyboardShortcuts.clickToEdit")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-grow justify-between items-center gap-2">
|
||||
{conflicts.length > 0 ? (
|
||||
<p className="text-red-400 text-sm">
|
||||
{conflicts.length}{" "}
|
||||
{conflicts.length > 1
|
||||
? t("global.keyboardShortcuts.conflicts")
|
||||
: t("global.keyboardShortcuts.conflict")}{" "}
|
||||
{t("global.keyboardShortcuts.detected")}
|
||||
</p>
|
||||
) : (
|
||||
<div /> // Empty div to take up space
|
||||
)}
|
||||
<Button theme="secondary" onClick={handleResetAll}>
|
||||
<Icon icon={Icons.RELOAD} className="mr-2" />
|
||||
{t("global.keyboardShortcuts.resetAllToDefault")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 max-h-[60vh] overflow-y-auto">
|
||||
{shortcutGroups.map((group) => (
|
||||
<div key={group.title} className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2">
|
||||
{group.title}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{group.shortcuts.map((shortcut) => {
|
||||
const isEditing = editingId === shortcut.id;
|
||||
const hasConflict = conflictIds.has(shortcut.id);
|
||||
const config = editingShortcuts[shortcut.id];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={shortcut.id}
|
||||
className="flex items-center justify-between py-1"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown
|
||||
selectedItem={
|
||||
modifierOptions.find(
|
||||
(opt) => opt.id === editingModifier,
|
||||
) || modifierOptions[0]
|
||||
}
|
||||
setSelectedItem={(item) =>
|
||||
setEditingModifier(
|
||||
item.id as KeyboardModifier | "",
|
||||
)
|
||||
}
|
||||
options={modifierOptions}
|
||||
className="w-32 !my-1"
|
||||
/>
|
||||
<KeyBadge
|
||||
config={
|
||||
editingKey
|
||||
? {
|
||||
modifier:
|
||||
editingModifier || undefined,
|
||||
key: editingKey,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
editing
|
||||
>
|
||||
{isCapturingKey
|
||||
? t("global.keyboardShortcuts.pressKey")
|
||||
: editingKey
|
||||
? getKeyDisplayName(editingKey)
|
||||
: t("global.keyboardShortcuts.none")}
|
||||
</KeyBadge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={handleSaveEdit}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
{t("global.keyboardShortcuts.save")}
|
||||
</Button>
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={handleCancelEdit}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
{t("global.keyboardShortcuts.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<KeyBadge
|
||||
config={config}
|
||||
onClick={() => handleStartEdit(shortcut.id)}
|
||||
hasConflict={hasConflict}
|
||||
>
|
||||
{config?.key
|
||||
? getKeyDisplayName(config.key)
|
||||
: t("global.keyboardShortcuts.none")}
|
||||
</KeyBadge>
|
||||
<span className="text-type-secondary">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{shortcut.condition && !isEditing && (
|
||||
<span className="text-xs text-gray-400 italic">
|
||||
{shortcut.condition}
|
||||
</span>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResetShortcut(shortcut.id)}
|
||||
className="text-type-secondary hover:text-white transition-colors"
|
||||
title={t(
|
||||
"global.keyboardShortcuts.resetToDefault",
|
||||
)}
|
||||
>
|
||||
<Icon icon={Icons.RELOAD} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-700">
|
||||
<Button theme="secondary" onClick={handleCancel}>
|
||||
{t("global.keyboardShortcuts.cancel")}
|
||||
</Button>
|
||||
<Button theme="purple" onClick={handleSave}>
|
||||
{t("global.keyboardShortcuts.saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCard>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<kbd className="inline-flex items-center justify-center min-w-[2rem] h-8 px-2 text-sm font-mono bg-gray-800 text-gray-200 rounded border border-gray-600 shadow-sm">
|
||||
<kbd className="relative inline-flex items-center justify-center min-w-[2rem] h-8 px-2 text-sm font-mono bg-gray-800 text-gray-200 rounded border border-gray-600 shadow-sm">
|
||||
{children}
|
||||
{modifier && (
|
||||
<span className="absolute -top-1 -right-1 text-xs bg-blue-600 text-white rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{getModifierSymbol(modifier)}
|
||||
</span>
|
||||
)}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
const getShortcutGroups = (
|
||||
t: (key: string) => string,
|
||||
shortcuts: Record<string, KeyboardShortcutConfig>,
|
||||
): 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 (
|
||||
<Modal id={id}>
|
||||
|
|
@ -178,12 +233,23 @@ export function KeyboardCommandsModal({ id }: KeyboardCommandsModalProps) {
|
|||
return (
|
||||
<>
|
||||
{before}
|
||||
<KeyBadge>`</KeyBadge>
|
||||
<KeyBadge config={undefined}>`</KeyBadge>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</p>
|
||||
<p className="text-type-secondary text-sm mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigate("/settings?category=settings-preferences");
|
||||
}}
|
||||
className="text-type-link hover:text-type-linkHover"
|
||||
>
|
||||
{t("global.keyboardShortcuts.editInSettings")}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 max-h-[60vh] overflow-y-auto">
|
||||
|
|
@ -193,24 +259,28 @@ export function KeyboardCommandsModal({ id }: KeyboardCommandsModalProps) {
|
|||
{group.title}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{group.shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.key}
|
||||
className="flex items-center justify-between py-1"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyBadge>{shortcut.key}</KeyBadge>
|
||||
<span className="text-type-secondary">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
{group.shortcuts
|
||||
.filter((shortcut) => shortcut.key) // Only show shortcuts that have a key configured
|
||||
.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.key}
|
||||
className="flex items-center justify-between py-1"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyBadge config={shortcut.config}>
|
||||
{shortcut.key}
|
||||
</KeyBadge>
|
||||
<span className="text-type-secondary">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
</div>
|
||||
{shortcut.condition && (
|
||||
<span className="text-xs text-gray-400 italic">
|
||||
{shortcut.condition}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{shortcut.condition && (
|
||||
<span className="text-xs text-gray-400 italic">
|
||||
{shortcut.condition}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,12 @@ export function useModal(id: string) {
|
|||
};
|
||||
}
|
||||
|
||||
export function ModalCard(props: { children?: ReactNode }) {
|
||||
export function ModalCard(props: {
|
||||
children?: ReactNode;
|
||||
className?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full max-w-[30rem] m-4">
|
||||
<div className={classNames("w-full max-w-[30rem] m-4", props.className)}>
|
||||
<div className="w-full bg-modal-background rounded-xl p-8 pointer-events-auto">
|
||||
{props.children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof setTimeout> | 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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Preference */}
|
||||
<div>
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.preferences.keyboardShortcuts")}
|
||||
</p>
|
||||
<p className="max-w-[25rem] font-medium">
|
||||
{t("settings.preferences.keyboardShortcutsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={() => showModal("keyboard-commands-edit")}
|
||||
>
|
||||
{t("settings.preferences.keyboardShortcutsLabel")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Column */}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<LanguageProvider />
|
||||
<NotificationModal id="notifications" />
|
||||
<KeyboardCommandsModal id="keyboard-commands" />
|
||||
<KeyboardCommandsEditModal id="keyboard-commands-edit" />
|
||||
<SupportInfoModal id="support-info" />
|
||||
<DetailsModal id="details" />
|
||||
<DetailsModal id="discover-details" />
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
298
src/utils/keyboardShortcuts.ts
Normal file
298
src/utils/keyboardShortcuts.ts
Normal file
|
|
@ -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<string, KeyboardShortcutConfig>;
|
||||
|
||||
/**
|
||||
* 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<string, string> = {
|
||||
// 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<string, string> = {
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
" ": "Space",
|
||||
};
|
||||
|
||||
return displayNames[key] || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display symbol for a modifier
|
||||
*/
|
||||
export function getModifierSymbol(modifier: KeyboardModifier): string {
|
||||
return modifier === "Shift" ? "⇧" : "⌥";
|
||||
}
|
||||
Loading…
Reference in a new issue