customize keyboard shortcuts!

This commit is contained in:
Pas 2025-12-23 23:26:59 -07:00
parent 71852409c2
commit cb504ccf55
11 changed files with 1308 additions and 202 deletions

View file

@ -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",

View file

@ -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(

View 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>
);
}

View file

@ -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>
))}

View file

@ -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>

View file

@ -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");

View file

@ -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,
],
);

View file

@ -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 */}

View file

@ -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" />

View file

@ -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",

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