From f2e5111ee5cab476d1463f01308a0588c685c0d4 Mon Sep 17 00:00:00 2001
From: Arnei
Date: Wed, 4 Feb 2026 12:51:47 +0100
Subject: [PATCH 1/8] Add configurable hotkeys
Allows users to configure their own hotkeys.
This should empower users to choose the hotkeys that
work for them, instead of having to use the cumbersome
"Shift + Alt + something" combinations.
Configured hotkeys are persisted in local storage.
To that end, this patch introduces the redux-persist package and
all the necessary boilerplate that
it requires.
This also makes more changes to KeyboardControls.tsx than
strictly necessary in the hopes
of making it a little less complicated.
---
package-lock.json | 10 +
package.json | 1 +
src/globalKeys.ts | 3 +-
src/i18n/locales/en-US.json | 9 +-
src/index.tsx | 12 +-
src/main/CuttingActions.tsx | 59 +++---
src/main/CuttingActionsContextMenu.tsx | 20 +-
src/main/KeyboardControls.tsx | 250 +++++++++++++++++++++----
src/main/SubtitleListEditor.tsx | 24 +--
src/main/SubtitleTimeline.tsx | 23 +--
src/main/Timeline.tsx | 28 +--
src/main/VideoControls.tsx | 35 ++--
src/redux/hotkeySlice.ts | 43 +++++
src/redux/store.ts | 47 +++--
14 files changed, 418 insertions(+), 146 deletions(-)
create mode 100644 src/redux/hotkeySlice.ts
diff --git a/package-lock.json b/package-lock.json
index 3c75cbb9c..3f95eae1a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,6 +44,7 @@
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "^1.8.11",
"redux": "^5.0.1",
+ "redux-persist": "^6.0.0",
"smol-toml": "^1.4.0",
"standardized-audio-context": "^25.3.77",
"typescript": "^5.8.3",
@@ -6490,6 +6491,15 @@
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
+ "node_modules/redux-persist": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz",
+ "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": ">4.0.0"
+ }
+ },
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
diff --git a/package.json b/package.json
index 4be5afa0a..e36805340 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "^1.8.11",
"redux": "^5.0.1",
+ "redux-persist": "^6.0.0",
"smol-toml": "^1.4.0",
"standardized-audio-context": "^25.3.77",
"typescript": "^5.8.3",
diff --git a/src/globalKeys.ts b/src/globalKeys.ts
index a4e1769e8..75570ba12 100644
--- a/src/globalKeys.ts
+++ b/src/globalKeys.ts
@@ -64,7 +64,8 @@ export const subtitleListHotkeysDefaultOptions: object = {
const cuttingZoomInSplitKey = ";";
-export const KEYMAP: IKeyMap = {
+
+export const DEFAULT_KEYMAP: IKeyMap = {
videoPlayer: {
play: {
name: "keyboardControls.videoPlayButton",
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
index 32ed51f01..163f0f852 100644
--- a/src/i18n/locales/en-US.json
+++ b/src/i18n/locales/en-US.json
@@ -350,7 +350,14 @@
"scrubberLeft": "Move left",
"scrubberRight": "Move right",
"scrubberIncrease": "Move faster",
- "scrubberDecrease": "Move slower"
+ "scrubberDecrease": "Move slower",
+ "changeModal": {
+ "title": "Change hotkey: {{name}}",
+ "info": "Record a new hotkey sequence by pressing the keys you want.",
+ "recordedKeys": "Recorded keys: ",
+ "save": "Save",
+ "discard": "Discard"
+ }
},
"theme": {
diff --git a/src/index.tsx b/src/index.tsx
index dbb9f7311..657bd713c 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -14,6 +14,8 @@ import "./i18n/config";
import "@opencast/appkit/dist/colors.css";
import { ColorSchemeProvider } from "@opencast/appkit";
+import { PersistGate } from "redux-persist/lib/integration/react";
+import { persistStore } from "redux-persist";
const container = document.getElementById("root");
if (!container) {
@@ -21,6 +23,8 @@ if (!container) {
}
const root = ReactDOMClient.createRoot(container);
+// Commenting persistent stuff out can help with debugging
+const persistor = persistStore(store);
// Load config here
// Load the rest of the application and try to fetch the settings file from the
@@ -36,9 +40,11 @@ initialize.then(
root.render(
-
-
-
+ loading...} persistor={persistor}>
+
+
+
+
,
);
diff --git a/src/main/CuttingActions.tsx b/src/main/CuttingActions.tsx
index 86c0e6572..f20d16ada 100644
--- a/src/main/CuttingActions.tsx
+++ b/src/main/CuttingActions.tsx
@@ -26,7 +26,7 @@ import {
timelineZoomIn,
timelineZoomOut,
} from "../redux/videoSlice";
-import { KEYMAP, rewriteKeys } from "../globalKeys";
+import { rewriteKeys } from "../globalKeys";
import { ActionCreatorWithoutPayload, ActionCreatorWithPayload, PayloadActionCreator } from "@reduxjs/toolkit";
import { useTranslation } from "react-i18next";
@@ -36,6 +36,7 @@ import { Slider } from "@mui/material";
import { useHotkeys } from "react-hotkeys-hook";
import { ProtoButton } from "@opencast/appkit";
import Select, { components, SingleValue } from "react-select";
+import { selectKeymap } from "../redux/hotkeySlice";
/**
* Defines the different actions a user can perform while in cutting mode
@@ -65,6 +66,8 @@ const CuttingActions: React.FC<{
// Init redux variables
const dispatch = useAppDispatch();
+ const keymap = useAppSelector(selectKeymap);
+
/**
* General action callback for cutting actions
* @param event event triggered by click or button press
@@ -92,39 +95,39 @@ const CuttingActions: React.FC<{
// Maps functions to hotkeys
useHotkeys(
- KEYMAP.cutting.cut.key,
+ keymap.cutting.cut.key,
() => dispatchAction(cut),
- KEYMAP.cutting.cut.options,
+ keymap.cutting.cut.options,
[cut],
);
useHotkeys(
- KEYMAP.cutting.delete.key,
+ keymap.cutting.delete.key,
() => dispatchAction(markAsDeletedOrAlive),
- KEYMAP.cutting.delete.options,
+ keymap.cutting.delete.options,
[markAsDeletedOrAlive],
);
useHotkeys(
- KEYMAP.cutting.mergeLeft.key,
+ keymap.cutting.mergeLeft.key,
() => dispatchAction(mergeLeft),
- KEYMAP.cutting.mergeLeft.options,
+ keymap.cutting.mergeLeft.options,
[mergeLeft],
);
useHotkeys(
- KEYMAP.cutting.mergeRight.key,
+ keymap.cutting.mergeRight.key,
() => dispatchAction(mergeRight),
- KEYMAP.cutting.mergeRight.options,
+ keymap.cutting.mergeRight.options,
[mergeRight],
);
useHotkeys(
- KEYMAP.cutting.zoomIn.key,
+ keymap.cutting.zoomIn.key,
() => dispatchAction(timelineZoomIn),
- KEYMAP.cutting.zoomIn.options,
+ keymap.cutting.zoomIn.options,
[timelineZoomIn],
);
useHotkeys(
- KEYMAP.cutting.zoomOut.key,
+ keymap.cutting.zoomOut.key,
() => dispatchAction(timelineZoomOut),
- KEYMAP.cutting.zoomOut.options,
+ keymap.cutting.zoomOut.options,
[timelineZoomOut],
);
@@ -152,8 +155,8 @@ const CuttingActions: React.FC<{
action={cut}
actionWithPayload={undefined}
payload={undefined}
- tooltip={t("cuttingActions.cut-tooltip", { hotkeyName: rewriteKeys(KEYMAP.cutting.cut.key) })}
- ariaLabelText={t("cuttingActions.cut-tooltip-aria", { hotkeyName: rewriteKeys(KEYMAP.cutting.cut.key) })}
+ tooltip={t("cuttingActions.cut-tooltip", { hotkeyName: rewriteKeys(keymap.cutting.cut.key) })}
+ ariaLabelText={t("cuttingActions.cut-tooltip-aria", { hotkeyName: rewriteKeys(keymap.cutting.cut.key) })}
/>
>
@@ -166,8 +169,8 @@ const CuttingActions: React.FC<{
action={add}
actionWithPayload={undefined}
payload={undefined}
- tooltip={t("cuttingActions.add-tooltip", { hotkeyName: rewriteKeys(KEYMAP.cutting.cut.key) })}
- ariaLabelText={t("cuttingActions.add-tooltip-aria", { hotkeyName: rewriteKeys(KEYMAP.cutting.cut.key) })}
+ tooltip={t("cuttingActions.add-tooltip", { hotkeyName: rewriteKeys(keymap.cutting.cut.key) })}
+ ariaLabelText={t("cuttingActions.add-tooltip-aria", { hotkeyName: rewriteKeys(keymap.cutting.cut.key) })}
/>
>
@@ -175,7 +178,7 @@ const CuttingActions: React.FC<{
{!isDeleteButtonDisabled &&
<>
>
@@ -188,9 +191,9 @@ const CuttingActions: React.FC<{
action={deleteByMerge}
actionWithPayload={undefined}
payload={undefined}
- tooltip={t("cuttingActions.deleteByMerge-tooltip", { hotkeyName: rewriteKeys(KEYMAP.cutting.delete.key) })}
+ tooltip={t("cuttingActions.deleteByMerge-tooltip", { hotkeyName: rewriteKeys(keymap.cutting.delete.key) })}
ariaLabelText={t("cuttingActions.deleteByMerge-tooltip-aria",
- { hotkeyName: rewriteKeys(KEYMAP.cutting.delete.key) })}
+ { hotkeyName: rewriteKeys(keymap.cutting.delete.key) })}
/>
>
@@ -203,9 +206,9 @@ const CuttingActions: React.FC<{
action={mergeLeft}
actionWithPayload={undefined}
payload={undefined}
- tooltip={t("cuttingActions.mergeLeft-tooltip", { hotkeyName: rewriteKeys(KEYMAP.cutting.mergeLeft.key) })}
+ tooltip={t("cuttingActions.mergeLeft-tooltip", { hotkeyName: rewriteKeys(keymap.cutting.mergeLeft.key) })}
ariaLabelText={
- t("cuttingActions.mergeLeft-tooltip-aria", { hotkeyName: rewriteKeys(KEYMAP.cutting.mergeLeft.key) })
+ t("cuttingActions.mergeLeft-tooltip-aria", { hotkeyName: rewriteKeys(keymap.cutting.mergeLeft.key) })
}
/>
@@ -219,9 +222,9 @@ const CuttingActions: React.FC<{
action={mergeRight}
actionWithPayload={undefined}
payload={undefined}
- tooltip={t("cuttingActions.mergeRight-tooltip", { hotkeyName: rewriteKeys(KEYMAP.cutting.mergeRight.key) })}
+ tooltip={t("cuttingActions.mergeRight-tooltip", { hotkeyName: rewriteKeys(keymap.cutting.mergeRight.key) })}
ariaLabelText={
- t("cuttingActions.mergeRight-tooltip-aria", { hotkeyName: rewriteKeys(KEYMAP.cutting.mergeRight.key) })
+ t("cuttingActions.mergeRight-tooltip-aria", { hotkeyName: rewriteKeys(keymap.cutting.mergeRight.key) })
}
/>
@@ -257,12 +260,12 @@ const CuttingActions: React.FC<{
}
diff --git a/src/main/CuttingActionsContextMenu.tsx b/src/main/CuttingActionsContextMenu.tsx
index f5a0138ec..92892aae7 100644
--- a/src/main/CuttingActionsContextMenu.tsx
+++ b/src/main/CuttingActionsContextMenu.tsx
@@ -5,9 +5,10 @@ import { LuChevronLeft, LuChevronRight, LuScissors, LuTrash } from "react-icons/
import TrashRestore from "../img/trash-restore.svg?react";
import { ContextMenuItem, ThemedContextMenu } from "./ContextMenu";
-import { KEYMAP, rewriteKeys } from "../globalKeys";
+import { rewriteKeys } from "../globalKeys";
import { useAppDispatch, useAppSelector } from "../redux/store";
import { cut, markAsDeletedOrAlive, mergeLeft, mergeRight, selectIsCurrentSegmentAlive } from "../redux/videoSlice";
+import { selectKeymap } from "../redux/hotkeySlice";
const CuttingActionsContextMenu: React.FC<{
children: React.ReactNode,
@@ -19,6 +20,7 @@ const CuttingActionsContextMenu: React.FC<{
// Init redux variables
const dispatch = useAppDispatch();
+ const keymap = useAppSelector(selectKeymap);
const isCurrentSegmentAlive = useAppSelector(selectIsCurrentSegmentAlive);
const cuttingContextMenuItems: ContextMenuItem[] = [
@@ -26,36 +28,36 @@ const CuttingActionsContextMenu: React.FC<{
name: t("cuttingActions.cut-button"),
action: () => dispatch(cut()),
icon: LuScissors,
- hotKey: KEYMAP.cutting.cut.key,
+ hotKey: keymap.cutting.cut.key,
ariaLabel: t("cuttingActions.cut-tooltip-aria", {
- hotkeyName: rewriteKeys(KEYMAP.cutting.cut.key),
+ hotkeyName: rewriteKeys(keymap.cutting.cut.key),
}),
},
{
name: isCurrentSegmentAlive ? t("cuttingActions.delete-button") : t("cuttingActions.restore-button"),
action: () => dispatch(markAsDeletedOrAlive()),
icon: isCurrentSegmentAlive ? LuTrash : TrashRestore,
- hotKey: KEYMAP.cutting.delete.key,
+ hotKey: keymap.cutting.delete.key,
ariaLabel: t("cuttingActions.delete-restore-tooltip-aria", {
- hotkeyName: rewriteKeys(KEYMAP.cutting.delete.key),
+ hotkeyName: rewriteKeys(keymap.cutting.delete.key),
}),
},
{
name: t("cuttingActions.mergeLeft-button"),
action: () => dispatch(mergeLeft()),
icon: LuChevronLeft,
- hotKey: KEYMAP.cutting.mergeLeft.key,
+ hotKey: keymap.cutting.mergeLeft.key,
ariaLabel: t("cuttingActions.mergeLeft-tooltip-aria", {
- hotkeyName: rewriteKeys(KEYMAP.cutting.mergeLeft.key),
+ hotkeyName: rewriteKeys(keymap.cutting.mergeLeft.key),
}),
},
{
name: t("cuttingActions.mergeRight-button"),
action: () => dispatch(mergeRight()),
icon: LuChevronRight,
- hotKey: KEYMAP.cutting.mergeRight.key,
+ hotKey: keymap.cutting.mergeRight.key,
ariaLabel: t("cuttingActions.mergeRight-tooltip-aria", {
- hotkeyName: rewriteKeys(KEYMAP.cutting.mergeRight.key),
+ hotkeyName: rewriteKeys(keymap.cutting.mergeRight.key),
}),
},
];
diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx
index bb27f0f66..284a4af5d 100644
--- a/src/main/KeyboardControls.tsx
+++ b/src/main/KeyboardControls.tsx
@@ -1,21 +1,28 @@
+import React from "react";
import { css } from "@emotion/react";
import { ParseKeys } from "i18next";
-
-import React from "react";
-
import { useTranslation, Trans } from "react-i18next";
-import { getGroupName, KEYMAP, rewriteKeys } from "../globalKeys";
-import { useTheme } from "../themes";
-import { titleStyle, titleStyleBold } from "../cssStyles";
+import { getGroupName, IKey, IKeyGroup, rewriteKeys } from "../globalKeys";
+import { Theme, useTheme } from "../themes";
+import { basicButtonStyle, titleStyle, titleStyleBold } from "../cssStyles";
+import { useRecordHotkeys } from "react-hotkeys-hook";
+import { useAppDispatch, useAppSelector } from "../redux/store";
+import { selectKeymap, setHotkey } from "../redux/hotkeySlice";
+import { Modal, ModalHandle, ProtoButton } from "@opencast/appkit";
+import { LuPen } from "react-icons/lu";
-const Group: React.FC<{ name: ParseKeys, entries: { [key: string]: string[][]; }; }> = ({ name, entries }) => {
+const Group: React.FC<{
+ id: string
+ entries: IKeyGroup
+ openEditModal: (group: string, action: string, actionTitle: string) => void
+}> = ({ id, entries, openEditModal }) => {
const { t } = useTranslation();
const theme = useTheme();
const groupStyle = css({
display: "flex",
- flexDirection: "column" as const,
+ flexDirection: "column",
width: "460px",
maxWidth: "50vw",
@@ -32,19 +39,42 @@ const Group: React.FC<{ name: ParseKeys, entries: { [key: string]: string[][]; }
return (
-
{t(name)}
- {Object.entries(entries).map(([key, value], index) =>
- ,
+ {t(getGroupName(id))}
+ {Object.entries(entries).map(([entryId, value]) =>
+ ,
)}
);
};
-const Entry: React.FC<{ name: string, sequences: string[][]; }> = ({ name, sequences }) => {
+const Entry: React.FC<{
+ id: string
+ entry: IKey
+ groupId: string
+ openEditModal: (group: string, action: string, actionTitle: string) => void
+}> = ({ id, entry, groupId, openEditModal }) => {
const { t } = useTranslation();
const theme = useTheme();
+ const formatEntry = (entry: IKey) => {
+ let formattedSequences: string[][] = [];
+
+ const sequences = entry.key.split(",").map(item => item.trim());
+ const sequenceSplitKey = entry.splitKey ?? "+";
+ formattedSequences = Object.entries(sequences).map(([, sequence]) => {
+ return sequence.split(sequenceSplitKey).map(item => rewriteKeys(item.trim()));
+ });
+
+ return formattedSequences;
+ };
+
const entryStyle = css({
display: "flex",
flexFlow: "column nowrap",
@@ -62,6 +92,19 @@ const Entry: React.FC<{ name: string, sequences: string[][]; }> = ({ name, seque
color: `${theme.text}`,
});
+ const entryContentStyle = css({
+ display: "flex",
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ });
+
+ const sequencesStyle = css({
+ display: "flex",
+ flexFlow: "column",
+ gap: "10px",
+ });
+
const sequenceStyle = css({
display: "flex",
flexDirection: "row",
@@ -85,58 +128,88 @@ const Entry: React.FC<{ name: string, sequences: string[][]; }> = ({ name, seque
fontWeight: "bold",
});
+ const editButtonStyle = css({
+ padding: "16px",
+ });
+
return (
-
{name || t("keyboardControls.missingLabel")}
- {sequences.map((sequence, index, arr) => (
-
- {sequence.map((singleKey, index) => (
-
-
- {singleKey}
-
- {sequence.length - 1 !== index &&
-
+
- }
+
{entry.name || t("keyboardControls.missingLabel")}
+
+
+ {formatEntry(entry).map((sequence, index, arr) => (
+
+ {sequence.map((singleKey, index) => (
+
+
+ {singleKey}
+
+ {sequence.length - 1 !== index &&
+
+
+ }
+
+ ))}
+
+ {arr.length - 1 !== index && t("keyboardControls.sequenceSeparator")}
+
))}
-
- {arr.length - 1 !== index && t("keyboardControls.sequenceSeparator")}
-
- ))}
+
openEditModal(groupId, id, t(entry.name as ParseKeys))}
+ >
+
+
+
);
};
const KeyboardControls: React.FC = () => {
-
const { t } = useTranslation();
const theme = useTheme();
+ const keymap = useAppSelector(selectKeymap);
+
+ const [keys, { start, stop, resetKeys }] = useRecordHotkeys();
+ const modalRef = React.useRef
(null);
+ const [editGroup, setEditGroup] = React.useState("");
+ const [editAction, setEditAction] = React.useState("");
+ const [editActionTitle, setEditActionTitle] = React.useState("");
+
+ const openEditModal = (group: string, action: string, actionTitle: string) => {
+ setEditGroup(group);
+ setEditAction(action);
+ setEditActionTitle(actionTitle);
+
+ resetKeys();
+ start();
+
+ if (modalRef.current) {
+ modalRef.current?.open();
+ }
+ };
const groupsStyle = css({
display: "flex",
- flexDirection: "row" as const,
+ flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: "30px",
});
const render = () => {
- if (KEYMAP && Object.keys(KEYMAP).length > 0) {
+ if (keymap && Object.keys(keymap).length > 0) {
const groups: JSX.Element[] = [];
- Object.entries(KEYMAP).forEach(([groupName, group], index) => {
- const entries: { [groupName: string]: string[][]; } = {};
- Object.entries(group).forEach(([, action]) => {
- const sequences = action.key.split(",").map(item => item.trim());
- const sequenceSplitKey = action.splitKey ? action.splitKey : "+";
- entries[action.name] = Object.entries(sequences).map(([, sequence]) => {
- return sequence.split(sequenceSplitKey).map(item => rewriteKeys(item.trim()));
- });
- });
- groups.push();
+ Object.entries(keymap).forEach(([groupId, group]) => {
+ groups.push();
});
return (
@@ -152,7 +225,7 @@ const KeyboardControls: React.FC = () => {
const keyboardControlsStyle = css({
display: "flex",
- flexDirection: "column" as const,
+ flexDirection: "column",
alignItems: "center",
width: "100%",
});
@@ -163,9 +236,104 @@ const KeyboardControls: React.FC = () => {
{t("keyboardControls.header")}
+
+
{render()}
);
};
+const ChangeHotkeyModal: React.FC<{
+ modalRef: React.RefObject,
+ keys: Set,
+ stop: () => void
+ group: string,
+ action: string,
+ actionTitle: string,
+}> = ({
+ modalRef,
+ keys,
+ stop,
+ group,
+ action,
+ actionTitle,
+}) => {
+ const dispatch = useAppDispatch();
+ const { t } = useTranslation();
+ const theme = useTheme();
+
+ const setNewKeys = () => {
+ stop();
+
+ dispatch(setHotkey({
+ group: group,
+ action: action,
+ key: Array.from(keys).join(" + "),
+ }));
+
+ if (modalRef.current?.close) {
+ modalRef.current.close();
+ }
+ };
+
+ const modalContentStyle = css({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ width: "100%",
+ });
+
+ const buttonsStyle = css({
+ display: "flex",
+ flexDirection: "row",
+ width: "100%",
+ justifyContent: "space-around",
+ });
+
+ const buttonStyle = (theme: Theme) => css({
+ fontSize: "16px",
+ padding: "12px 16px",
+ justifyContent: "space-around",
+ boxShadow: `${theme.boxShadow}`,
+ background: `${theme.element_bg}`,
+ });
+
+ return (
+
+
+
{t("keyboardControls.changeModal.info")}
+
{t("keyboardControls.changeModal.recordedKeys")}
+
{Array.from(keys).join(" + ")}
+
+
+
+ {t("keyboardControls.changeModal.save")}
+
+
+ {t("keyboardControls.changeModal.discard")}
+
+
+
+
+ );
+
+};
+
export default KeyboardControls;
diff --git a/src/main/SubtitleListEditor.tsx b/src/main/SubtitleListEditor.tsx
index 68929f06d..d0cdf1e90 100644
--- a/src/main/SubtitleListEditor.tsx
+++ b/src/main/SubtitleListEditor.tsx
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
import { shallowEqual } from "react-redux";
import { RootState, useAppDispatch, useAppSelector } from "../redux/store";
import { basicButtonStyle } from "../cssStyles";
-import { KEYMAP, subtitleListHotkeysDefaultOptions } from "../globalKeys";
+import { subtitleListHotkeysDefaultOptions } from "../globalKeys";
import { SubtitleCue, SubtitlesInEditor } from "../types";
import { convertMsToReadableString } from "../util/utilityFunctions";
import { ListChildComponentProps, VariableSizeList } from "react-window";
@@ -21,6 +21,7 @@ import { ProtoButton, useColorScheme } from "@opencast/appkit";
import { ActionCreatorWithPayload } from "@reduxjs/toolkit";
import { setCues } from "../redux/chapterSlice";
import { selectDuration } from "../redux/videoSlice";
+import { selectKeymap } from "../redux/hotkeySlice";
/**
* Displays everything needed to edit subtitles
@@ -305,6 +306,7 @@ const SubtitleListSegment : React.FC<{
const theme = useTheme();
const dispatch = useAppDispatch();
+ const keymap = useAppSelector(selectKeymap);
const duration = useAppSelector(selectDuration);
const currentlyAt = useAppSelector(selectCurrentlyAt);
// Unfortunately, the focus selectors will cause every element to rerender,
@@ -438,28 +440,28 @@ const SubtitleListSegment : React.FC<{
// Maps functions to hotkeys
const hotkeyRef = useHotkeys([
- KEYMAP.subtitleList.addAbove.key,
- KEYMAP.subtitleList.addBelow.key,
- KEYMAP.subtitleList.jumpAbove.key,
- KEYMAP.subtitleList.jumpBelow.key,
- KEYMAP.subtitleList.delete.key,
+ keymap.subtitleList.addAbove.key,
+ keymap.subtitleList.addBelow.key,
+ keymap.subtitleList.jumpAbove.key,
+ keymap.subtitleList.jumpBelow.key,
+ keymap.subtitleList.delete.key,
], (_, handler) => {
switch (handler.keys?.join("")) {
- case KEYMAP.subtitleList.addAbove.key.split("+").pop():
+ case keymap.subtitleList.addAbove.key.split("+").pop():
addCueAbove();
break;
- case KEYMAP.subtitleList.addBelow.key.split("+").pop():
+ case keymap.subtitleList.addBelow.key.split("+").pop():
addCueBelow();
break;
- case KEYMAP.subtitleList.jumpAbove.key.split("+").pop():
+ case keymap.subtitleList.jumpAbove.key.split("+").pop():
dispatch(setFocusSegmentTriggered(true));
dispatch(setFocusToSegmentAboveId({ identifier: identifier, segmentId: cue.idInternal }));
break;
- case KEYMAP.subtitleList.jumpBelow.key.split("+").pop():
+ case keymap.subtitleList.jumpBelow.key.split("+").pop():
dispatch(setFocusSegmentTriggered(true));
dispatch(setFocusToSegmentBelowId({ identifier: identifier, segmentId: cue.idInternal }));
break;
- case KEYMAP.subtitleList.delete.key.split("+").pop():
+ case keymap.subtitleList.delete.key.split("+").pop():
dispatch(setFocusSegmentTriggered(true));
dispatch(setFocusToSegmentAboveId({ identifier: identifier, segmentId: cue.idInternal }));
deleteCue();
diff --git a/src/main/SubtitleTimeline.tsx b/src/main/SubtitleTimeline.tsx
index bf7064787..49937184a 100644
--- a/src/main/SubtitleTimeline.tsx
+++ b/src/main/SubtitleTimeline.tsx
@@ -25,9 +25,9 @@ import { useTheme } from "../themes";
import { ThemedTooltip } from "./Tooltip";
import { useTranslation } from "react-i18next";
import { useHotkeys } from "react-hotkeys-hook";
-import { KEYMAP } from "../globalKeys";
import { shallowEqual } from "react-redux";
import TimelineStamps from "./TimelineStamps";
+import { selectKeymap } from "../redux/hotkeySlice";
/**
* Copy-paste of the timeline in Video.tsx, so that we can make some small adjustments,
@@ -40,6 +40,7 @@ const SubtitleTimeline: React.FC = () => {
// Init redux variables
const dispatch = useAppDispatch();
+ const keymap = useAppSelector(selectKeymap);
const duration = useAppSelector(selectDuration);
const currentlyAt = useAppSelector(selectCurrentlyAt);
const subtitleId = useAppSelector(selectSelectedSubtitleId, shallowEqual);
@@ -101,33 +102,33 @@ const SubtitleTimeline: React.FC = () => {
// TODO: Better increases and decreases than ten intervals
// TODO: Additional helpful controls (e.g. jump to start/end of segment/next segment)
useHotkeys(
- KEYMAP.timeline.left.key,
+ keymap.timeline.left.key,
() => dispatch(setCurrentlyAt(Math.max(currentlyAt - keyboardJumpDelta, 0))),
- KEYMAP.timeline.left.options,
+ keymap.timeline.left.options,
[currentlyAt, keyboardJumpDelta],
);
useHotkeys(
- KEYMAP.timeline.right.key,
+ keymap.timeline.right.key,
() => dispatch(setCurrentlyAt(Math.min(currentlyAt + keyboardJumpDelta, duration))),
- KEYMAP.timeline.right.options,
+ keymap.timeline.right.options,
[currentlyAt, keyboardJumpDelta, duration],
);
useHotkeys(
- KEYMAP.timeline.increase.key,
+ keymap.timeline.increase.key,
() => setKeyboardJumpDelta(keyboardJumpDelta => Math.min(keyboardJumpDelta * 10, 1000000)),
- KEYMAP.timeline.increase.options,
+ keymap.timeline.increase.options,
[keyboardJumpDelta],
);
useHotkeys(
- KEYMAP.timeline.decrease.key,
+ keymap.timeline.decrease.key,
() => setKeyboardJumpDelta(keyboardJumpDelta => Math.max(keyboardJumpDelta / 10, 1)),
- KEYMAP.timeline.decrease.options,
+ keymap.timeline.decrease.options,
[keyboardJumpDelta],
);
useHotkeys(
- KEYMAP.subtitleList.addCue.key,
+ keymap.subtitleList.addCue.key,
() => addCue(currentlyAt),
- KEYMAP.subtitleList.addCue.options,
+ keymap.subtitleList.addCue.options,
[currentlyAt],
);
diff --git a/src/main/Timeline.tsx b/src/main/Timeline.tsx
index 4b1488af4..13fc8c2ac 100644
--- a/src/main/Timeline.tsx
+++ b/src/main/Timeline.tsx
@@ -26,7 +26,7 @@ import useResizeObserver from "use-resize-observer";
import { Waveform } from "../util/waveform";
import { convertMsToReadableString } from "../util/utilityFunctions";
-import { KEYMAP, rewriteKeys } from "../globalKeys";
+import { rewriteKeys } from "../globalKeys";
import { useTranslation } from "react-i18next";
import { ActionCreatorWithPayload } from "@reduxjs/toolkit";
@@ -43,6 +43,7 @@ import {
moveCut as chapterMoveCut,
} from "../redux/chapterSlice";
import TimelineStamps from "./TimelineStamps";
+import { selectKeymap } from "../redux/hotkeySlice";
/**
* A container for visualizing the cutting of the video, as well as for controlling
@@ -259,6 +260,7 @@ export const Scrubber = React.forwardRef((props,
// Init redux variables
const dispatch = useAppDispatch();
+ const keymap = useAppSelector(selectKeymap);
const isPlaying = useAppSelector(selectIsPlaying);
const currentlyAt = useAppSelector(selectCurrentlyAt);
const duration = useAppSelector(selectDuration);
@@ -339,27 +341,27 @@ export const Scrubber = React.forwardRef((props,
// TODO: Better increases and decreases than ten intervals
// TODO: Additional helpful controls (e.g. jump to start/end of segment/next segment)
useHotkeys(
- KEYMAP.timeline.left.key,
+ keymap.timeline.left.key,
() => dispatch(setCurrentlyAt(Math.max(currentlyAt - keyboardJumpDelta, 0))),
- KEYMAP.timeline.left.options,
+ keymap.timeline.left.options,
[currentlyAt, keyboardJumpDelta],
);
useHotkeys(
- KEYMAP.timeline.right.key,
+ keymap.timeline.right.key,
() => dispatch(setCurrentlyAt(Math.min(currentlyAt + keyboardJumpDelta, duration))),
- KEYMAP.timeline.right.options,
+ keymap.timeline.right.options,
[currentlyAt, keyboardJumpDelta, duration],
);
useHotkeys(
- KEYMAP.timeline.increase.key,
+ keymap.timeline.increase.key,
() => setKeyboardJumpDelta(keyboardJumpDelta => Math.min(keyboardJumpDelta * 10, 1000000)),
- KEYMAP.timeline.increase.options,
+ keymap.timeline.increase.options,
[keyboardJumpDelta],
);
useHotkeys(
- KEYMAP.timeline.decrease.key,
+ keymap.timeline.decrease.key,
() => setKeyboardJumpDelta(keyboardJumpDelta => Math.max(keyboardJumpDelta / 10, 1)),
- KEYMAP.timeline.decrease.options,
+ keymap.timeline.decrease.options,
[keyboardJumpDelta],
);
@@ -427,10 +429,10 @@ export const Scrubber = React.forwardRef((props,
currentTime: convertMsToReadableString(currentlyAt),
segment: activeSegmentIndex,
segmentStatus: (activeSegment && activeSegment.deleted ? "Deleted" : "Alive"),
- moveLeft: rewriteKeys(KEYMAP.timeline.left.key),
- moveRight: rewriteKeys(KEYMAP.timeline.right.key),
- increase: rewriteKeys(KEYMAP.timeline.increase.key),
- decrease: rewriteKeys(KEYMAP.timeline.decrease.key),
+ moveLeft: rewriteKeys(keymap.timeline.left.key),
+ moveRight: rewriteKeys(keymap.timeline.right.key),
+ increase: rewriteKeys(keymap.timeline.increase.key),
+ decrease: rewriteKeys(keymap.timeline.decrease.key),
})}
tabIndex={0}>
diff --git a/src/main/VideoControls.tsx b/src/main/VideoControls.tsx
index 4b09b2f67..31f3298b2 100644
--- a/src/main/VideoControls.tsx
+++ b/src/main/VideoControls.tsx
@@ -13,7 +13,7 @@ import {
import { convertMsToReadableString } from "../util/utilityFunctions";
import { BREAKPOINTS, basicButtonStyle, undisplay, undisplayContainer } from "../cssStyles";
-import { KEYMAP, rewriteKeys } from "../globalKeys";
+import { rewriteKeys } from "../globalKeys";
import { useTranslation } from "react-i18next";
import { RootState } from "../redux/store";
@@ -24,6 +24,7 @@ import { Theme, useTheme } from "../themes";
import { useHotkeys } from "react-hotkeys-hook";
import { Slider } from "@mui/material";
import { ProtoButton } from "@opencast/appkit";
+import { selectKeymap } from "../redux/hotkeySlice";
/**
* Contains controls for manipulating multiple video players at once
@@ -125,6 +126,7 @@ const PreviewMode: React.FC<{
// Init redux variables
const dispatch = useAppDispatch();
+ const keymap = useAppSelector(selectKeymap);
const isPlayPreview = useAppSelector(selectIsPlayPreview);
const theme = useTheme();
@@ -140,9 +142,9 @@ const PreviewMode: React.FC<{
// Maps functions to hotkeys
useHotkeys(
- KEYMAP.videoPlayer.preview.key,
+ keymap.videoPlayer.preview.key,
() => switchPlayPreview(undefined),
- KEYMAP.videoPlayer.preview.options,
+ keymap.videoPlayer.preview.options,
[isPlayPreview],
);
@@ -168,13 +170,13 @@ const PreviewMode: React.FC<{
return (
switchPlayPreview(ref)}
onKeyDown={(event: React.KeyboardEvent
) => {
if (event.key === " ") {
@@ -207,6 +209,7 @@ const PlayButton: React.FC<{
// Init redux variables
const dispatch = useAppDispatch();
+ const keymap = useAppSelector(selectKeymap);
const isPlaying = useAppSelector(selectIsPlaying);
const theme = useTheme();
@@ -217,9 +220,9 @@ const PlayButton: React.FC<{
// Maps functions to hotkeys
useHotkeys(
- KEYMAP.videoPlayer.play.key,
+ keymap.videoPlayer.play.key,
() => switchIsPlaying(),
- KEYMAP.videoPlayer.play.options,
+ keymap.videoPlayer.play.options,
[isPlaying],
);
@@ -268,15 +271,16 @@ const PreviousButton: React.FC<{
const dispatch = useAppDispatch();
const theme = useTheme();
+ const keymap = useAppSelector(selectKeymap);
const jumpToPrevious = () => {
dispatch(jumpToPreviousSegment());
};
useHotkeys(
- KEYMAP.videoPlayer.previous.key,
+ keymap.videoPlayer.previous.key,
() => jumpToPrevious(),
- KEYMAP.videoPlayer.previous.options,
+ keymap.videoPlayer.previous.options,
);
const previousIconStyle = css({
@@ -285,11 +289,11 @@ const PreviousButton: React.FC<{
return (
{
if (event.key === "Enter") {
@@ -314,15 +318,16 @@ const NextButton: React.FC<{
const dispatch = useAppDispatch();
const theme = useTheme();
+ const keymap = useAppSelector(selectKeymap);
const jumpToNext = () => {
dispatch(jumpToNextSegment());
};
useHotkeys(
- KEYMAP.videoPlayer.next.key,
+ keymap.videoPlayer.next.key,
() => jumpToNext(),
- KEYMAP.videoPlayer.next.options,
+ keymap.videoPlayer.next.options,
);
const nextIconStyle = css({
@@ -330,9 +335,9 @@ const NextButton: React.FC<{
});
return (
-
+
>;
+
+const hotkeysSlice = createSlice({
+ name: "hotkeys",
+ initialState: {} as HotkeyOverrides,
+
+ reducers: {
+ setHotkey: (state, action: PayloadAction<{ group: string; action: string; key: string }>) => {
+ const { group, action: actionName, key } = action.payload;
+
+ state[group] ??= {};
+ state[group][actionName] = key;
+ },
+ resetHotkey: (state, action: PayloadAction<{ group: string; action: string }>) => {
+ delete state[action.payload.group]?.[action.payload.action];
+ },
+ resetAllHotkeys: () => ({}),
+ },
+});
+
+export const selectKeymap = createSelector(
+ [(state: RootState) => state.hotkeyState],
+ (overrides): IKeyMap => {
+ const merged = structuredClone(DEFAULT_KEYMAP);
+
+ for (const group in overrides) {
+ for (const action in overrides[group]) {
+ merged[group][action].key = overrides[group][action];
+ }
+ }
+
+ return merged;
+ },
+);
+
+
+export const { setHotkey, resetHotkey, resetAllHotkeys } = hotkeysSlice.actions;
+
+export default hotkeysSlice.reducer;
diff --git a/src/redux/store.ts b/src/redux/store.ts
index cbaf9c987..d9b980eb9 100644
--- a/src/redux/store.ts
+++ b/src/redux/store.ts
@@ -1,4 +1,4 @@
-import { configureStore } from "@reduxjs/toolkit";
+import { combineReducers, configureStore } from "@reduxjs/toolkit";
import mainMenuStateReducer from "./mainMenuSlice";
import finishStateReducer from "./finishSlice";
import videoReducer from "./videoSlice";
@@ -8,22 +8,43 @@ import metadataReducer from "./metadataSlice";
import subtitleReducer from "./subtitleSlice";
import chapterReducer from "./chapterSlice";
import thumbnailReducer from "./thumbnailSlice";
+import hotkeyReducer from "./hotkeySlice";
import errorReducer from "./errorSlice";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
+import storage from "redux-persist/lib/storage";
+import { FLUSH, PAUSE, PERSIST, persistReducer, PURGE, REGISTER, REHYDRATE } from "redux-persist";
+
+const reducers = combineReducers({
+ mainMenuState: mainMenuStateReducer,
+ finishState: finishStateReducer,
+ videoState: videoReducer,
+ workflowPostState: workflowPostReducer,
+ endState: endReducer,
+ metadataState: metadataReducer,
+ subtitleState: subtitleReducer,
+ chapterState: chapterReducer,
+ thumbnailState: thumbnailReducer,
+ hotkeyState: hotkeyReducer,
+ errorState: errorReducer,
+});
+
+const persistConfig = {
+ key: "root",
+ storage,
+ whitelist: ["hotkeyState"], // persist hotkeys
+};
+
+const persistedReducer = persistReducer(persistConfig, reducers);
export const store = configureStore({
- reducer: {
- mainMenuState: mainMenuStateReducer,
- finishState: finishStateReducer,
- videoState: videoReducer,
- workflowPostState: workflowPostReducer,
- endState: endReducer,
- metadataState: metadataReducer,
- subtitleState: subtitleReducer,
- chapterState: chapterReducer,
- thumbnailState: thumbnailReducer,
- errorState: errorReducer,
- },
+ reducer: persistedReducer,
+
+ middleware: getDefaultMiddleware =>
+ getDefaultMiddleware({
+ serializableCheck: {
+ ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
+ },
+ }),
});
export type AppDispatch = typeof store.dispatch;
From ae7afc5145d376d7fb5b16b6617e8edb86605220 Mon Sep 17 00:00:00 2001
From: Arnei
Date: Wed, 15 Apr 2026 14:47:55 +0200
Subject: [PATCH 2/8] Fix key recorder assuming QWERTY keyboard
With this changed the key recorder or hotkeys
should listen for the produced key, better supporting alternative
keyboards.
---
src/main/KeyboardControls.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx
index 284a4af5d..930335dc1 100644
--- a/src/main/KeyboardControls.tsx
+++ b/src/main/KeyboardControls.tsx
@@ -172,7 +172,7 @@ const KeyboardControls: React.FC = () => {
const theme = useTheme();
const keymap = useAppSelector(selectKeymap);
- const [keys, { start, stop, resetKeys }] = useRecordHotkeys();
+ const [keys, { start, stop, resetKeys }] = useRecordHotkeys(true);
const modalRef = React.useRef(null);
const [editGroup, setEditGroup] = React.useState("");
const [editAction, setEditAction] = React.useState("");
From f6243dafc78971aa080584ec5915a0630b27ca8a Mon Sep 17 00:00:00 2001
From: Arnei
Date: Wed, 15 Apr 2026 16:13:36 +0200
Subject: [PATCH 3/8] Properly handle unsetting hotkeys
Setting a hotkey to nothing was possibly before, but felt very
unintentional as the user had to save an empty hotkey sequence.
This prevents users from saving empty hotkeys sequences, and instead
gives them an explicit button for unsetting.
---
src/i18n/locales/en-US.json | 7 ++
src/main/KeyboardControls.tsx | 174 ++++++++++++++++++++++++++++------
2 files changed, 151 insertions(+), 30 deletions(-)
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
index 163f0f852..073f5bf44 100644
--- a/src/i18n/locales/en-US.json
+++ b/src/i18n/locales/en-US.json
@@ -351,12 +351,19 @@
"scrubberRight": "Move right",
"scrubberIncrease": "Move faster",
"scrubberDecrease": "Move slower",
+ "keyUndefined": "Undefined",
"changeModal": {
"title": "Change hotkey: {{name}}",
"info": "Record a new hotkey sequence by pressing the keys you want.",
"recordedKeys": "Recorded keys: ",
"save": "Save",
"discard": "Discard"
+ },
+ "deleteModal": {
+ "title": "Delete hotkey: {{name}}",
+ "info": "Unsets the hotkey, rendering it unusable.",
+ "confirm": "Confirm",
+ "cancel": "Cancel"
}
},
diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx
index 930335dc1..36725ed02 100644
--- a/src/main/KeyboardControls.tsx
+++ b/src/main/KeyboardControls.tsx
@@ -4,18 +4,19 @@ import { ParseKeys } from "i18next";
import { useTranslation, Trans } from "react-i18next";
import { getGroupName, IKey, IKeyGroup, rewriteKeys } from "../globalKeys";
import { Theme, useTheme } from "../themes";
-import { basicButtonStyle, titleStyle, titleStyleBold } from "../cssStyles";
+import { basicButtonStyle, deactivatedButtonStyle, titleStyle, titleStyleBold } from "../cssStyles";
import { useRecordHotkeys } from "react-hotkeys-hook";
import { useAppDispatch, useAppSelector } from "../redux/store";
import { selectKeymap, setHotkey } from "../redux/hotkeySlice";
import { Modal, ModalHandle, ProtoButton } from "@opencast/appkit";
-import { LuPen } from "react-icons/lu";
+import { LuPen, LuTrash } from "react-icons/lu";
const Group: React.FC<{
id: string
entries: IKeyGroup
openEditModal: (group: string, action: string, actionTitle: string) => void
-}> = ({ id, entries, openEditModal }) => {
+ openDeleteModal: (group: string, action: string, actionTitle: string) => void
+}> = ({ id, entries, openEditModal, openDeleteModal }) => {
const { t } = useTranslation();
const theme = useTheme();
@@ -47,6 +48,7 @@ const Group: React.FC<{
entry={value}
groupId={id}
openEditModal={openEditModal}
+ openDeleteModal={openDeleteModal}
/>,
)}
@@ -58,7 +60,8 @@ const Entry: React.FC<{
entry: IKey
groupId: string
openEditModal: (group: string, action: string, actionTitle: string) => void
-}> = ({ id, entry, groupId, openEditModal }) => {
+ openDeleteModal: (group: string, action: string, actionTitle: string) => void
+}> = ({ id, entry, groupId, openEditModal, openDeleteModal }) => {
const { t } = useTranslation();
const theme = useTheme();
@@ -136,31 +139,43 @@ const Entry: React.FC<{
{entry.name || t("keyboardControls.missingLabel")}
-
- {formatEntry(entry).map((sequence, index, arr) => (
-
- {sequence.map((singleKey, index) => (
-
-
- {singleKey}
+ {entry.key === "" ?
+
{t("keyboardControls.keyUndefined")}
+ :
+
+ {formatEntry(entry).map((sequence, index, arr) => (
+
+ {sequence.map((singleKey, index) => (
+
+
+ {singleKey}
+
+ {sequence.length - 1 !== index &&
+
+
+ }
- {sequence.length - 1 !== index &&
-
+
- }
-
- ))}
-
- {arr.length - 1 !== index && t("keyboardControls.sequenceSeparator")}
-
-
- ))}
+ ))}
+
+ {arr.length - 1 !== index && t("keyboardControls.sequenceSeparator")}
+
+
+ ))}
+
+ }
+
+
openEditModal(groupId, id, t(entry.name as ParseKeys))}
+ >
+
+
+
openDeleteModal(groupId, id, t(entry.name as ParseKeys))}
+ >
+
+
-
openEditModal(groupId, id, t(entry.name as ParseKeys))}
- >
-
-
);
@@ -174,6 +189,7 @@ const KeyboardControls: React.FC = () => {
const [keys, { start, stop, resetKeys }] = useRecordHotkeys(true);
const modalRef = React.useRef
(null);
+ const modalRefDelete = React.useRef(null);
const [editGroup, setEditGroup] = React.useState("");
const [editAction, setEditAction] = React.useState("");
const [editActionTitle, setEditActionTitle] = React.useState("");
@@ -191,6 +207,16 @@ const KeyboardControls: React.FC = () => {
}
};
+ const openDeleteModal = (group: string, action: string, actionTitle: string) => {
+ setEditGroup(group);
+ setEditAction(action);
+ setEditActionTitle(actionTitle);
+
+ if (modalRefDelete.current) {
+ modalRefDelete.current?.open();
+ }
+ };
+
const groupsStyle = css({
display: "flex",
flexDirection: "row",
@@ -209,6 +235,7 @@ const KeyboardControls: React.FC = () => {
id={groupId}
entries={group}
openEditModal={openEditModal}
+ openDeleteModal={openDeleteModal}
/>);
});
@@ -245,6 +272,13 @@ const KeyboardControls: React.FC = () => {
actionTitle={editActionTitle}
/>
+
+
{render()}
);
@@ -318,22 +352,102 @@ const ChangeHotkeyModal: React.FC<{
+ {t("keyboardControls.changeModal.discard")}
+
+
{t("keyboardControls.changeModal.save")}
+
+
+
+ );
+};
+
+const DeleteHotkeyModal: React.FC<{
+ modalRef: React.RefObject,
+ group: string,
+ action: string,
+ actionTitle: string,
+}> = ({
+ modalRef,
+ group,
+ action,
+ actionTitle,
+}) => {
+ const dispatch = useAppDispatch();
+ const { t } = useTranslation();
+ const theme = useTheme();
+
+ const unsetHotkey = () => {
+ dispatch(setHotkey({
+ group: group,
+ action: action,
+ key: "",
+ }));
+
+ if (modalRef.current?.close) {
+ modalRef.current.close();
+ }
+ };
+
+ const modalContentStyle = css({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ width: "100%",
+ });
+
+ const buttonsStyle = css({
+ display: "flex",
+ flexDirection: "row",
+ width: "100%",
+ justifyContent: "space-around",
+ });
+
+ const buttonStyle = (theme: Theme) => css({
+ fontSize: "16px",
+ padding: "12px 16px",
+ justifyContent: "space-around",
+ boxShadow: `${theme.boxShadow}`,
+ background: `${theme.element_bg}`,
+ });
+
+ return (
+
+
+
{t("keyboardControls.deleteModal.info")}
+
+
- {t("keyboardControls.changeModal.discard")}
+ {t("keyboardControls.deleteModal.cancel")}
+
+
+ {t("keyboardControls.deleteModal.confirm")}
);
-
};
+
export default KeyboardControls;
From 6ea96000eece6a636168650661f8cdf507326300 Mon Sep 17 00:00:00 2001
From: Arnei
Date: Thu, 16 Apr 2026 13:36:31 +0200
Subject: [PATCH 4/8] Prevent duplicate hotkey sequences
Do not allow a user to set a hotkey if that hotkey already exists.
Disables in the save button in such a case and shows a warning
message.
---
src/i18n/locales/en-US.json | 1 +
src/main/KeyboardControls.tsx | 51 ++++++++++++++++++++++++++++++++---
2 files changed, 49 insertions(+), 3 deletions(-)
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
index 073f5bf44..25100889b 100644
--- a/src/i18n/locales/en-US.json
+++ b/src/i18n/locales/en-US.json
@@ -352,6 +352,7 @@
"scrubberIncrease": "Move faster",
"scrubberDecrease": "Move slower",
"keyUndefined": "Undefined",
+ "alreadyInUse": "Key sequence is already in use. Please choose a different sequence.",
"changeModal": {
"title": "Change hotkey: {{name}}",
"info": "Record a new hotkey sequence by pressing the keys you want.",
diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx
index 36725ed02..9c53e6de6 100644
--- a/src/main/KeyboardControls.tsx
+++ b/src/main/KeyboardControls.tsx
@@ -2,7 +2,7 @@ import React from "react";
import { css } from "@emotion/react";
import { ParseKeys } from "i18next";
import { useTranslation, Trans } from "react-i18next";
-import { getGroupName, IKey, IKeyGroup, rewriteKeys } from "../globalKeys";
+import { getGroupName, IKey, IKeyGroup, IKeyMap, rewriteKeys } from "../globalKeys";
import { Theme, useTheme } from "../themes";
import { basicButtonStyle, deactivatedButtonStyle, titleStyle, titleStyleBold } from "../cssStyles";
import { useRecordHotkeys } from "react-hotkeys-hook";
@@ -303,6 +303,8 @@ const ChangeHotkeyModal: React.FC<{
const { t } = useTranslation();
const theme = useTheme();
+ const keymap = useAppSelector(selectKeymap);
+
const setNewKeys = () => {
stop();
@@ -339,6 +341,9 @@ const ChangeHotkeyModal: React.FC<{
background: `${theme.element_bg}`,
});
+ const keyIsAlreadyPresent = isKeyInKeymap(keymap, Array.from(keys));
+ const disabled = keys.size === 0 || keyIsAlreadyPresent;
+
return (
{t("keyboardControls.changeModal.info")}
{t("keyboardControls.changeModal.recordedKeys")}
{Array.from(keys).join(" + ")}
+ {keyIsAlreadyPresent ? {t("keyboardControls.alreadyInUse")}
: null}
{t("keyboardControls.changeModal.save")}
@@ -449,5 +455,44 @@ const DeleteHotkeyModal: React.FC<{
);
};
+function isKeyInKeymap(
+ keymap: IKeyMap,
+ targetKeys: string[],
+): boolean {
+ if (!targetKeys || targetKeys.length === 0) {
+ return false;
+ }
+
+ for (const group of Object.values(keymap)) {
+ for (const action of Object.values(group)) {
+ const sequenceSeparator = ",";
+ const sequences = action.key
+ .split(sequenceSeparator)
+ .map(k => k.trim());
+
+ const targetKeysTransformed = targetKeys
+ .map(v => v.toLowerCase())
+ .sort();
+
+ for (const sequence of sequences) {
+ const keySeparator = action.splitKey ?? "+";
+
+ const keys = sequence
+ .split(keySeparator)
+ .map(k => k.trim())
+ .map(k => k.toLowerCase());
+
+ const setKeys = new Set(keys);
+ const setTargetKeys = new Set(targetKeysTransformed);
+
+ if (setKeys.symmetricDifference(setTargetKeys).size === 0) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+}
+
export default KeyboardControls;
From 263aeaeee093c29848586a01ed1c0109f751620e Mon Sep 17 00:00:00 2001
From: Arnei
Date: Thu, 16 Apr 2026 13:54:34 +0200
Subject: [PATCH 5/8] Add a reset button
To allow user to reset a hotkey to its default sequence(s).
---
src/main/KeyboardControls.tsx | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx
index 9c53e6de6..8adf5afc9 100644
--- a/src/main/KeyboardControls.tsx
+++ b/src/main/KeyboardControls.tsx
@@ -7,9 +7,9 @@ import { Theme, useTheme } from "../themes";
import { basicButtonStyle, deactivatedButtonStyle, titleStyle, titleStyleBold } from "../cssStyles";
import { useRecordHotkeys } from "react-hotkeys-hook";
import { useAppDispatch, useAppSelector } from "../redux/store";
-import { selectKeymap, setHotkey } from "../redux/hotkeySlice";
+import { resetHotkey, selectKeymap, setHotkey } from "../redux/hotkeySlice";
import { Modal, ModalHandle, ProtoButton } from "@opencast/appkit";
-import { LuPen, LuTrash } from "react-icons/lu";
+import { LuPen, LuRotateCcw, LuTrash } from "react-icons/lu";
const Group: React.FC<{
id: string
@@ -65,6 +65,7 @@ const Entry: React.FC<{
const { t } = useTranslation();
const theme = useTheme();
+ const dispatch = useAppDispatch();
const formatEntry = (entry: IKey) => {
let formattedSequences: string[][] = [];
@@ -132,7 +133,7 @@ const Entry: React.FC<{
});
const editButtonStyle = css({
- padding: "16px",
+ padding: "12px",
});
return (
@@ -175,6 +176,12 @@ const Entry: React.FC<{
>
+
dispatch(resetHotkey({ group: groupId, action: id }))}
+ >
+
+
From 151b1253cab8be11fad65ce26626adc58f95b582 Mon Sep 17 00:00:00 2001
From: Arnei
Date: Thu, 16 Apr 2026 13:59:05 +0200
Subject: [PATCH 6/8] Prevent users from using "+" for hotkeys
We use "+" for delimiting purposes, so it would be problematic
if user included that in their hotkeys sequence.
---
src/i18n/locales/en-US.json | 1 +
src/main/KeyboardControls.tsx | 4 +++-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
index 25100889b..d5eb0f0c8 100644
--- a/src/i18n/locales/en-US.json
+++ b/src/i18n/locales/en-US.json
@@ -353,6 +353,7 @@
"scrubberDecrease": "Move slower",
"keyUndefined": "Undefined",
"alreadyInUse": "Key sequence is already in use. Please choose a different sequence.",
+ "containsPlus": "The '+' character is not allowed. Please choose a different sequence",
"changeModal": {
"title": "Change hotkey: {{name}}",
"info": "Record a new hotkey sequence by pressing the keys you want.",
diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx
index 8adf5afc9..e4292666d 100644
--- a/src/main/KeyboardControls.tsx
+++ b/src/main/KeyboardControls.tsx
@@ -348,8 +348,9 @@ const ChangeHotkeyModal: React.FC<{
background: `${theme.element_bg}`,
});
+ const containsPlus = keys.has("+");
const keyIsAlreadyPresent = isKeyInKeymap(keymap, Array.from(keys));
- const disabled = keys.size === 0 || keyIsAlreadyPresent;
+ const disabled = keys.size === 0 || keyIsAlreadyPresent || containsPlus;
return (
{t("keyboardControls.changeModal.recordedKeys")}
{Array.from(keys).join(" + ")}
{keyIsAlreadyPresent ? {t("keyboardControls.alreadyInUse")}
: null}
+ {containsPlus ? {t("keyboardControls.containsPlus")}
: null}
Date: Mon, 20 Apr 2026 13:24:45 +0200
Subject: [PATCH 7/8] Revert ae7afc5145d376d7fb5b16b6617e8edb86605220
Because we need the actual key for proper rendering.
---
src/main/KeyboardControls.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx
index e4292666d..eca607366 100644
--- a/src/main/KeyboardControls.tsx
+++ b/src/main/KeyboardControls.tsx
@@ -194,7 +194,7 @@ const KeyboardControls: React.FC = () => {
const theme = useTheme();
const keymap = useAppSelector(selectKeymap);
- const [keys, { start, stop, resetKeys }] = useRecordHotkeys(true);
+ const [keys, { start, stop, resetKeys }] = useRecordHotkeys();
const modalRef = React.useRef(null);
const modalRefDelete = React.useRef(null);
const [editGroup, setEditGroup] = React.useState("");
From a457babe08e1c0ddcd7b104f379e0a38f963979a Mon Sep 17 00:00:00 2001
From: Arnei
Date: Mon, 20 Apr 2026 13:36:35 +0200
Subject: [PATCH 8/8] Limit max amount of keys for sequence to 3
---
src/i18n/locales/en-US.json | 2 +-
src/main/KeyboardControls.tsx | 5 +++++
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
index d5eb0f0c8..d0db1949f 100644
--- a/src/i18n/locales/en-US.json
+++ b/src/i18n/locales/en-US.json
@@ -356,7 +356,7 @@
"containsPlus": "The '+' character is not allowed. Please choose a different sequence",
"changeModal": {
"title": "Change hotkey: {{name}}",
- "info": "Record a new hotkey sequence by pressing the keys you want.",
+ "info": "Record a new hotkey sequence by pressing the keys you want. Maximum are 3 keys.",
"recordedKeys": "Recorded keys: ",
"save": "Save",
"discard": "Discard"
diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx
index eca607366..1b72efbda 100644
--- a/src/main/KeyboardControls.tsx
+++ b/src/main/KeyboardControls.tsx
@@ -312,6 +312,11 @@ const ChangeHotkeyModal: React.FC<{
const keymap = useAppSelector(selectKeymap);
+ // Hard limit maximum amount of keys
+ if (keys.size >= 3) {
+ stop();
+ }
+
const setNewKeys = () => {
stop();