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..d0db1949f 100644
--- a/src/i18n/locales/en-US.json
+++ b/src/i18n/locales/en-US.json
@@ -350,7 +350,23 @@
"scrubberLeft": "Move left",
"scrubberRight": "Move right",
"scrubberIncrease": "Move faster",
- "scrubberDecrease": "Move slower"
+ "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. Maximum are 3 keys.",
+ "recordedKeys": "Recorded keys: ",
+ "save": "Save",
+ "discard": "Discard"
+ },
+ "deleteModal": {
+ "title": "Delete hotkey: {{name}}",
+ "info": "Unsets the hotkey, rendering it unusable.",
+ "confirm": "Confirm",
+ "cancel": "Cancel"
+ }
},
"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..1b72efbda 100644
--- a/src/main/KeyboardControls.tsx
+++ b/src/main/KeyboardControls.tsx
@@ -1,21 +1,29 @@
+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, IKeyMap, rewriteKeys } from "../globalKeys";
+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 { resetHotkey, selectKeymap, setHotkey } from "../redux/hotkeySlice";
+import { Modal, ModalHandle, ProtoButton } from "@opencast/appkit";
+import { LuPen, LuRotateCcw, LuTrash } 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
+ openDeleteModal: (group: string, action: string, actionTitle: string) => void
+}> = ({ id, entries, openEditModal, openDeleteModal }) => {
const { t } = useTranslation();
const theme = useTheme();
const groupStyle = css({
display: "flex",
- flexDirection: "column" as const,
+ flexDirection: "column",
width: "460px",
maxWidth: "50vw",
@@ -32,18 +40,44 @@ 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
+ openDeleteModal: (group: string, action: string, actionTitle: string) => void
+}> = ({ id, entry, groupId, openEditModal, openDeleteModal }) => {
const { t } = useTranslation();
const theme = useTheme();
+ const dispatch = useAppDispatch();
+
+ 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",
@@ -62,6 +96,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 +132,118 @@ const Entry: React.FC<{ name: string, sequences: string[][]; }> = ({ name, seque
fontWeight: "bold",
});
+ const editButtonStyle = css({
+ padding: "12px",
+ });
+
return (
-
{name || t("keyboardControls.missingLabel")}
- {sequences.map((sequence, index, arr) => (
-
- {sequence.map((singleKey, index) => (
-
-
- {singleKey}
+
{entry.name || t("keyboardControls.missingLabel")}
+
+ {entry.key === "" ?
+
{t("keyboardControls.keyUndefined")}
+ :
+
+ {formatEntry(entry).map((sequence, index, arr) => (
+
+ {sequence.map((singleKey, index) => (
+
+
+ {singleKey}
+
+ {sequence.length - 1 !== index &&
+
+
+ }
+
+ ))}
+
+ {arr.length - 1 !== index && t("keyboardControls.sequenceSeparator")}
+
- {sequence.length - 1 !== index &&
-
+
- }
-
- ))}
-
- {arr.length - 1 !== index && t("keyboardControls.sequenceSeparator")}
-
+ ))}
+
+ }
+
+
openEditModal(groupId, id, t(entry.name as ParseKeys))}
+ >
+
+
+
openDeleteModal(groupId, id, t(entry.name as ParseKeys))}
+ >
+
+
+
dispatch(resetHotkey({ group: groupId, action: id }))}
+ >
+
+
- ))}
+
);
};
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 modalRefDelete = 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 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" 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 +259,7 @@ const KeyboardControls: React.FC = () => {
const keyboardControlsStyle = css({
display: "flex",
- flexDirection: "column" as const,
+ flexDirection: "column",
alignItems: "center",
width: "100%",
});
@@ -163,9 +270,243 @@ 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 keymap = useAppSelector(selectKeymap);
+
+ // Hard limit maximum amount of keys
+ if (keys.size >= 3) {
+ stop();
+ }
+
+ 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}`,
+ });
+
+ const containsPlus = keys.has("+");
+ const keyIsAlreadyPresent = isKeyInKeymap(keymap, Array.from(keys));
+ const disabled = keys.size === 0 || keyIsAlreadyPresent || containsPlus;
+
+ return (
+
+
+
{t("keyboardControls.changeModal.info")}
+
{t("keyboardControls.changeModal.recordedKeys")}
+
{Array.from(keys).join(" + ")}
+ {keyIsAlreadyPresent ?
{t("keyboardControls.alreadyInUse")}
: null}
+ {containsPlus ?
{t("keyboardControls.containsPlus")}
: null}
+
+
+
+ {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.deleteModal.cancel")}
+
+
+ {t("keyboardControls.deleteModal.confirm")}
+
+
+
+
+ );
+};
+
+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;
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;