Feat/custom hotkeys (#496)
* feat: 2024-04-07 15:27:52 - custom hotkeys * feat: 2024-04-07 15:52:13 - add custome compare shortcuts * feat: 2024-04-09 10:17:23 - Modify the code according to the code review suggestions and optimize the experience. --------- Co-authored-by: more.tai <more.tai@huolala.cn>
This commit is contained in:
@@ -300,7 +300,7 @@
|
||||
"deleteRecording": "delete recording",
|
||||
"deleteRecordingConfirmation": "Are you sure to delete this recording?",
|
||||
"myRecordings": "recordings",
|
||||
"noRecordingForThisSegmentYet": "No recordings for this segment yet. Press <kbd>R</kbd> to start recording.",
|
||||
"noRecordingForThisSegmentYet": "No recordings for this segment yet. Press <kbd>{{key}}</kbd> to start recording.",
|
||||
"lastYear": "last year",
|
||||
"less": "less",
|
||||
"more": "more",
|
||||
@@ -522,6 +522,11 @@
|
||||
"AiDictionary": "AI dictionary",
|
||||
"AiTranslate": "AI translate",
|
||||
"cambridgeDictionary": "Cambridge dictionary",
|
||||
"customizeShortcuts": "Customize shortcuts",
|
||||
"customizeShortcutsTip":"Press any sequence of keys to set a shortcut",
|
||||
"customizeShortcutsInvalidToast": "Your shortcut should only have one modifier (Ctrl, Alt, Shift, or Meta) and one key, like 'Ctrl+C'.",
|
||||
"customizeShortcutsConflictToast": "{{input}} conflicts with the existing {{otherHotkeyName}} shortcut.",
|
||||
"customizeShortcutsUpdated": "Changes saved",
|
||||
"following": "following",
|
||||
"followers": "followers",
|
||||
"allUsers": "all users",
|
||||
|
||||
@@ -521,6 +521,11 @@
|
||||
"AiDictionary": "智能词典",
|
||||
"AiTranslate": "智能翻译",
|
||||
"cambridgeDictionary": "剑桥词典",
|
||||
"customizeShortcuts": "自定义快捷键",
|
||||
"customizeShortcutsTip":"按任意键序列设置快捷键",
|
||||
"customizeShortcutsInvalidToast":"快捷键应最多含一个修饰键(Ctrl、Alt、Shift 或 Meta)和一个键,如 'Ctrl+C'",
|
||||
"customizeShortcutsConflictToast": "{{input}}和已有{{otherHotkeyName}}的键位冲突了",
|
||||
"customizeShortcutsUpdated": "设置成功",
|
||||
"following": "关注中",
|
||||
"followers": "被关注",
|
||||
"allUsers": "所有用户",
|
||||
|
||||
@@ -34,7 +34,7 @@ const libraryPath = () => {
|
||||
settings.setSync(
|
||||
"library",
|
||||
process.env.LIBRARY_PATH ||
|
||||
path.join(app.getPath("documents"), LIBRARY_PATH_SUFFIX)
|
||||
path.join(app.getPath("documents"), LIBRARY_PATH_SUFFIX)
|
||||
);
|
||||
} else if (path.parse(_library).base !== LIBRARY_PATH_SUFFIX) {
|
||||
settings.setSync("library", path.join(_library, LIBRARY_PATH_SUFFIX));
|
||||
@@ -92,6 +92,7 @@ const userDataPath = () => {
|
||||
return userData;
|
||||
};
|
||||
|
||||
|
||||
export default {
|
||||
registerIpcHandlers: () => {
|
||||
ipcMain.handle("settings-get-library", (_event) => {
|
||||
@@ -152,6 +153,14 @@ export default {
|
||||
ipcMain.handle("settings-set-default-engine", (_event, engine) => {
|
||||
return settings.setSync("defaultEngine", engine);
|
||||
});
|
||||
|
||||
ipcMain.handle("settings-get-default-hotkeys", (_event) => {
|
||||
return settings.getSync("defaultHotkeys");
|
||||
});
|
||||
|
||||
ipcMain.handle("settings-set-default-hotkeys", (_event, records) => {
|
||||
return settings.setSync("defaultHotkeys", records);
|
||||
});
|
||||
},
|
||||
cachePath,
|
||||
libraryPath,
|
||||
|
||||
@@ -172,6 +172,12 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
switchLanguage: (language: string) => {
|
||||
return ipcRenderer.invoke("settings-switch-language", language);
|
||||
},
|
||||
getDefaultHotkeys: () => {
|
||||
return ipcRenderer.invoke("settings-get-default-hotkeys");
|
||||
},
|
||||
setDefaultHotkeys: (records: Record<string, string>) => {
|
||||
return ipcRenderer.invoke("settings-set-default-hotkeys", records);
|
||||
},
|
||||
},
|
||||
path: {
|
||||
join: (...paths: string[]) => {
|
||||
|
||||
@@ -3,12 +3,12 @@ import {
|
||||
AISettingsProvider,
|
||||
AppSettingsProvider,
|
||||
DbProvider,
|
||||
HotKeysSettingsProvider,
|
||||
} from "@renderer/context";
|
||||
import router from "./router";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { Toaster, toast } from "@renderer/components/ui";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
|
||||
function App() {
|
||||
window.__ENJOY_APP__.onNotification((_event, notification) => {
|
||||
@@ -31,32 +31,18 @@ function App() {
|
||||
}
|
||||
});
|
||||
|
||||
const ControlOrCommand = navigator.platform.includes("Mac")
|
||||
? "Meta"
|
||||
: "Control";
|
||||
|
||||
useHotkeys(`${ControlOrCommand}+Comma`, () => {
|
||||
document.getElementById("preferences-button")?.click();
|
||||
});
|
||||
|
||||
useHotkeys(`${ControlOrCommand}+Q`, () => {
|
||||
window.__ENJOY_APP__.app.quit();
|
||||
});
|
||||
|
||||
useHotkeys(`${ControlOrCommand}+Shift+I`, () => {
|
||||
window.__ENJOY_APP__.app.openDevTools();
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
|
||||
<AppSettingsProvider>
|
||||
<AISettingsProvider>
|
||||
<DbProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster richColors position="top-center" />
|
||||
<Tooltip id="global-tooltip" />
|
||||
</DbProvider>
|
||||
</AISettingsProvider>
|
||||
<HotKeysSettingsProvider>
|
||||
<AISettingsProvider>
|
||||
<DbProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster richColors position="top-center" />
|
||||
<Tooltip id="global-tooltip" />
|
||||
</DbProvider>
|
||||
</AISettingsProvider>
|
||||
</HotKeysSettingsProvider>
|
||||
</AppSettingsProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
103
enjoy/src/renderer/components/change-hotkey-dialog.tsx
Normal file
103
enjoy/src/renderer/components/change-hotkey-dialog.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@renderer/components/ui";
|
||||
import { toast } from "@renderer/components/ui";
|
||||
import { useContext, useMemo } from "react";
|
||||
import { HotKeysSettingsProviderContext } from "../context";
|
||||
import { CheckIcon, KeyboardIcon, XIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const ChangeHotkeyDialog = ({
|
||||
open,
|
||||
name,
|
||||
keyName,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
name: string;
|
||||
keyName: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
const {
|
||||
changeHotkey,
|
||||
currentHotkeys,
|
||||
recordingHotkeys,
|
||||
resetRecordingHotkeys,
|
||||
} = useContext(HotKeysSettingsProviderContext);
|
||||
|
||||
const joinedKeys = useMemo(() => [...recordingHotkeys].join("+"), [
|
||||
recordingHotkeys,
|
||||
]);
|
||||
|
||||
const changeKeyMap = async () => {
|
||||
const ret = ((await changeHotkey(
|
||||
keyName,
|
||||
recordingHotkeys
|
||||
)) as unknown) as {
|
||||
error: "conflict" | "invalid";
|
||||
data: string | string[];
|
||||
input: string;
|
||||
};
|
||||
const { error, data, input } = ret ?? {};
|
||||
if (error === "conflict") {
|
||||
toast.error(
|
||||
t("customizeShortcutsConflictToast", {
|
||||
input,
|
||||
otherHotkeyName: (data as string[]).join(","),
|
||||
})
|
||||
);
|
||||
} else if (error === "invalid") {
|
||||
toast.error(t("customizeShortcutsInvalidToast"));
|
||||
} else {
|
||||
toast.success(t("customizeShortcutsUpdated"));
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
resetRecordingHotkeys();
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("customizeShortcuts")}</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<div>
|
||||
<p className="pb-4">{name}</p>
|
||||
<div className="flex items-center">
|
||||
<p className="inline-block gap-2 border-2 border-black rounded p-[2px] mr-2">
|
||||
{currentHotkeys[keyName]}
|
||||
</p>
|
||||
{joinedKeys.length > 0 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="border-2 border-black rounded p-[2px]">
|
||||
{joinedKeys}
|
||||
</p>
|
||||
<div className="cursor-pointer" onClick={changeKeyMap}>
|
||||
<CheckIcon className="text-green-500 w-5 h-5" />
|
||||
</div>
|
||||
<div className="cursor-pointer" onClick={clear}>
|
||||
<XIcon className="text-red-500 w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-block gap-2 border-2 border-black rounded p-[2px]">
|
||||
<span className="text-sm">{t("customizeShortcutsTip")}</span>
|
||||
<KeyboardIcon className="inline ml-1 w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction>Close</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useContext, useRef, useState } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
HotKeysSettingsProviderContext,
|
||||
MediaPlayerProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { MediaRecorder, RecordingDetail } from "@renderer/components";
|
||||
@@ -60,6 +61,9 @@ export const MediaCurrentRecording = () => {
|
||||
currentTime: mediaCurrentTime,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
const { webApi, EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { enabled, currentHotkeys } = useContext(
|
||||
HotKeysSettingsProviderContext
|
||||
);
|
||||
const [player, setPlayer] = useState(null);
|
||||
const [regions, setRegions] = useState<Regions | null>(null);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
@@ -328,7 +332,7 @@ export const MediaCurrentRecording = () => {
|
||||
}
|
||||
|
||||
const subscriptions = [
|
||||
regions.on("region-created", () => { }),
|
||||
regions.on("region-created", () => {}),
|
||||
|
||||
regions.on("region-clicked", (region, e) => {
|
||||
e.stopPropagation();
|
||||
@@ -399,7 +403,7 @@ export const MediaCurrentRecording = () => {
|
||||
}, [currentRecording, isRecording, layout?.width]);
|
||||
|
||||
useHotkeys(
|
||||
["Ctrl+R", "Meta+R"],
|
||||
currentHotkeys.PlayOrPauseRecording,
|
||||
(keyboardEvent, hotkeyEvent) => {
|
||||
if (!player) return;
|
||||
keyboardEvent.preventDefault();
|
||||
@@ -411,6 +415,7 @@ export const MediaCurrentRecording = () => {
|
||||
document.getElementById("recording-play-or-pause-button").click();
|
||||
}
|
||||
},
|
||||
{ enabled },
|
||||
[player]
|
||||
);
|
||||
|
||||
@@ -422,7 +427,9 @@ export const MediaCurrentRecording = () => {
|
||||
<div
|
||||
className="m-auto"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t("noRecordingForThisSegmentYet"),
|
||||
__html: t("noRecordingForThisSegmentYet", {
|
||||
key: currentHotkeys.StartOrStopRecording?.toUpperCase(),
|
||||
}),
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
@@ -551,16 +558,17 @@ export const MediaCurrentRecording = () => {
|
||||
>
|
||||
<GaugeCircleIcon
|
||||
className={`w-4 h-4 mr-4
|
||||
${currentRecording.pronunciationAssessment
|
||||
? currentRecording.pronunciationAssessment
|
||||
.pronunciationScore >= 80
|
||||
? "text-green-500"
|
||||
: currentRecording.pronunciationAssessment
|
||||
.pronunciationScore >= 60
|
||||
? "text-yellow-600"
|
||||
: "text-red-500"
|
||||
: ""
|
||||
}
|
||||
${
|
||||
currentRecording.pronunciationAssessment
|
||||
? currentRecording.pronunciationAssessment
|
||||
.pronunciationScore >= 80
|
||||
? "text-green-500"
|
||||
: currentRecording.pronunciationAssessment
|
||||
.pronunciationScore >= 60
|
||||
? "text-yellow-600"
|
||||
: "text-red-500"
|
||||
: ""
|
||||
}
|
||||
`}
|
||||
/>
|
||||
<span>{t("pronunciationAssessment")}</span>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
MediaPlayerProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
HotKeysSettingsProviderContext,
|
||||
} from "@renderer/context";
|
||||
import {
|
||||
ScissorsIcon,
|
||||
@@ -56,6 +57,7 @@ export const MediaPlayerControls = () => {
|
||||
setTranscriptionDraft,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { currentHotkeys, enabled } = useContext(HotKeysSettingsProviderContext)
|
||||
const [playMode, setPlayMode] = useState<"loop" | "single" | "all">("single");
|
||||
const [playbackRate, setPlaybackRate] = useState<number>(1);
|
||||
const [grouping, setGrouping] = useState(false);
|
||||
@@ -372,30 +374,32 @@ export const MediaPlayerControls = () => {
|
||||
}, [wavesurfer, decoded, playMode, activeRegion, currentTime]);
|
||||
|
||||
useHotkeys(
|
||||
["Space", "p", "n", "r", "c"],
|
||||
[currentHotkeys.PlayOrPause, currentHotkeys.PlayPreviousSegment, currentHotkeys.PlayNextSegment, currentHotkeys.StartOrStopRecording, currentHotkeys.Compare],
|
||||
(keyboardEvent, hotkeyEvent) => {
|
||||
if (!wavesurfer) return;
|
||||
keyboardEvent.preventDefault();
|
||||
|
||||
switch (hotkeyEvent.keys.join("")) {
|
||||
case "space":
|
||||
case currentHotkeys.PlayOrPause.toLowerCase():
|
||||
document.getElementById("media-play-or-pause-button").click();
|
||||
break;
|
||||
case "p":
|
||||
case currentHotkeys.PlayPreviousSegment.toLowerCase():
|
||||
document.getElementById("media-play-previous-button").click();
|
||||
break;
|
||||
case "n":
|
||||
case currentHotkeys.PlayNextSegment.toLowerCase():
|
||||
document.getElementById("media-play-next-button").click();
|
||||
break;
|
||||
case "r":
|
||||
case currentHotkeys.StartOrStopRecording.toLowerCase():
|
||||
document.getElementById("media-record-button").click();
|
||||
break;
|
||||
case "c":
|
||||
case currentHotkeys.Compare.toLowerCase():
|
||||
document.getElementById("media-compare-button").click();
|
||||
break;
|
||||
}
|
||||
},{
|
||||
enabled
|
||||
},
|
||||
[wavesurfer]
|
||||
[wavesurfer, currentHotkeys]
|
||||
);
|
||||
|
||||
/*
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
HotKeysSettingsProviderContext,
|
||||
MediaPlayerProviderContext,
|
||||
} from "@renderer/context";
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ import { t } from "i18next";
|
||||
import { formatDateTime, formatDuration } from "@renderer/lib/utils";
|
||||
|
||||
export const MediaRecordings = () => {
|
||||
const { currentHotkeys } = useContext(HotKeysSettingsProviderContext);
|
||||
const containerRef = useRef<HTMLDivElement>();
|
||||
const {
|
||||
recordings = [],
|
||||
@@ -59,7 +61,9 @@ export const MediaRecordings = () => {
|
||||
<div
|
||||
className="text-center px-6 py-8 text-sm text-muted-foreground"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t("noRecordingForThisSegmentYet"),
|
||||
__html: t("noRecordingForThisSegmentYet", {
|
||||
key: currentHotkeys.StartOrStopRecording?.toUpperCase(),
|
||||
}),
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,45 @@
|
||||
import { t } from "i18next";
|
||||
import { Separator } from "@renderer/components/ui";
|
||||
import { HotKeysSettingsProviderContext, Hotkey } from "@/renderer/context";
|
||||
import { useContext, useState } from "react";
|
||||
import { ChangeHotkeyDialog } from "../change-hotkey-dialog";
|
||||
|
||||
export const Hotkeys = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<{
|
||||
name: string;
|
||||
keyName: string;
|
||||
} | null>(null);
|
||||
const {
|
||||
currentHotkeys,
|
||||
startRecordingHotkeys,
|
||||
stopRecordingHotkeys,
|
||||
} = useContext(HotKeysSettingsProviderContext);
|
||||
|
||||
const commandOrCtrl = navigator.platform.includes("Mac") ? "Cmd" : "Ctrl";
|
||||
|
||||
const handleItemSelected = (item: { name: string; keyName: Hotkey }) => {
|
||||
setOpen(true);
|
||||
startRecordingHotkeys();
|
||||
setSelectedItem(item);
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
stopRecordingHotkeys();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="font-semibold mb-4 capitilized">{t("hotkeys")}</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="text-sm text-muted-foreground">{t("system")}</div>
|
||||
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center space-x-2">{t("quitApp")}</div>
|
||||
<kbd className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground">
|
||||
<kbd className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-not-allowed">
|
||||
{commandOrCtrl} + Q
|
||||
</kbd>
|
||||
</div>
|
||||
@@ -24,8 +50,16 @@ export const Hotkeys = () => {
|
||||
<div className="flex items-center space-x-2">
|
||||
{t("openPreferences")}
|
||||
</div>
|
||||
<kbd className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground">
|
||||
{commandOrCtrl} + ,
|
||||
<kbd
|
||||
onClick={() =>
|
||||
handleItemSelected({
|
||||
name: "Open preferences",
|
||||
keyName: "OpenPreferences",
|
||||
})
|
||||
}
|
||||
className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{currentHotkeys.OpenPreferences}
|
||||
</kbd>
|
||||
</div>
|
||||
<Separator />
|
||||
@@ -36,8 +70,16 @@ export const Hotkeys = () => {
|
||||
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center space-x-2">{t("playOrPause")}</div>
|
||||
<kbd className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground">
|
||||
Space
|
||||
<kbd
|
||||
onClick={() =>
|
||||
handleItemSelected({
|
||||
name: t("playOrPause"),
|
||||
keyName: "PlayOrPause",
|
||||
})
|
||||
}
|
||||
className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{currentHotkeys.PlayOrPause}
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
@@ -47,8 +89,16 @@ export const Hotkeys = () => {
|
||||
<div className="flex items-center space-x-2 capitalize">
|
||||
{t("startOrStopRecording")}
|
||||
</div>
|
||||
<kbd className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground">
|
||||
r
|
||||
<kbd
|
||||
onClick={() =>
|
||||
handleItemSelected({
|
||||
name: t("startOrStopRecording"),
|
||||
keyName: "StartOrStopRecording",
|
||||
})
|
||||
}
|
||||
className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{currentHotkeys.StartOrStopRecording}
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
@@ -58,8 +108,16 @@ export const Hotkeys = () => {
|
||||
<div className="flex items-center space-x-2">
|
||||
{t("playOrPauseRecording")}
|
||||
</div>
|
||||
<kbd className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground">
|
||||
{commandOrCtrl} + r
|
||||
<kbd
|
||||
onClick={() =>
|
||||
handleItemSelected({
|
||||
name: t("playOrPauseRecording"),
|
||||
keyName: "PlayOrPauseRecording",
|
||||
})
|
||||
}
|
||||
className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{currentHotkeys.PlayOrPauseRecording}
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
@@ -69,8 +127,16 @@ export const Hotkeys = () => {
|
||||
<div className="flex items-center space-x-2 capitalize">
|
||||
{t("playPreviousSegment")}
|
||||
</div>
|
||||
<kbd className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground">
|
||||
p
|
||||
<kbd
|
||||
onClick={() =>
|
||||
handleItemSelected({
|
||||
name: t("playPreviousSegment"),
|
||||
keyName: "PlayPreviousSegment",
|
||||
})
|
||||
}
|
||||
className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{currentHotkeys.PlayPreviousSegment}
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
@@ -80,8 +146,16 @@ export const Hotkeys = () => {
|
||||
<div className="flex items-center space-x-2 capitalize">
|
||||
{t("playNextSegment")}
|
||||
</div>
|
||||
<kbd className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground">
|
||||
n
|
||||
<kbd
|
||||
onClick={() =>
|
||||
handleItemSelected({
|
||||
name: t("playNextSegment"),
|
||||
keyName: "PlayNextSegment",
|
||||
})
|
||||
}
|
||||
className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{currentHotkeys.PlayNextSegment}
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
@@ -91,14 +165,28 @@ export const Hotkeys = () => {
|
||||
<div className="flex items-center space-x-2 capitalize">
|
||||
{t("compare")}
|
||||
</div>
|
||||
<kbd className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground">
|
||||
c
|
||||
<kbd
|
||||
onClick={() =>
|
||||
handleItemSelected({
|
||||
name: t("compare"),
|
||||
keyName: "Compare",
|
||||
})
|
||||
}
|
||||
className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{currentHotkeys.Compare}
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
</div>
|
||||
|
||||
<ChangeHotkeyDialog
|
||||
open={open}
|
||||
keyName={selectedItem?.keyName}
|
||||
name={selectedItem?.name}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
236
enjoy/src/renderer/context/hotkeys-settings-provider.tsx
Normal file
236
enjoy/src/renderer/context/hotkeys-settings-provider.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useHotkeys, useRecordHotkeys } from "react-hotkeys-hook";
|
||||
import { AppSettingsProviderContext } from "./app-settings-provider";
|
||||
import _ from "lodash";
|
||||
|
||||
function isShortcutValid(shortcut: string) {
|
||||
const modifiers = ["ctrl", "alt", "shift", "meta"];
|
||||
const keys = shortcut.toLowerCase().split("+");
|
||||
const modifierCount = keys.filter((key) => modifiers.includes(key)).length;
|
||||
const normalKeyCount = keys.length - modifierCount;
|
||||
// Validation rule: At most one modifier key, and at most one regular key
|
||||
return modifierCount <= 1 && normalKeyCount === 1;
|
||||
}
|
||||
|
||||
function mergeWithPreference(
|
||||
a: Record<string, string>, // electron settings's cached value
|
||||
b: Record<string, string> // current version's default value
|
||||
): Record<string, string> {
|
||||
const c: Record<string, string> = {};
|
||||
|
||||
for (const key in b) {
|
||||
c[key] = b[key];
|
||||
}
|
||||
|
||||
for (const key in a) {
|
||||
if (key in b) {
|
||||
c[key] = a[key];
|
||||
}
|
||||
}
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
const ControlOrCommand = navigator.platform.includes("Mac")
|
||||
? "Meta"
|
||||
: "Control";
|
||||
|
||||
const defaultKeyMap = {
|
||||
// system
|
||||
QuitApp: `${ControlOrCommand}+Q`,
|
||||
OpenPreferences: `${ControlOrCommand}+Comma`,
|
||||
// player
|
||||
PlayOrPause: "Space",
|
||||
StartOrStopRecording: "r",
|
||||
PlayOrPauseRecording: `${ControlOrCommand}+r`,
|
||||
PlayPreviousSegment: "p",
|
||||
PlayNextSegment: "n",
|
||||
Compare: "c",
|
||||
// dev tools
|
||||
OpenDevTools: `${ControlOrCommand}+Shift+I`,
|
||||
};
|
||||
|
||||
export type Hotkey = keyof typeof defaultKeyMap;
|
||||
|
||||
function checkKeyAndValue(
|
||||
key: Hotkey,
|
||||
value: string,
|
||||
shortcuts: typeof defaultKeyMap
|
||||
) {
|
||||
const inputValue = value.toLowerCase();
|
||||
|
||||
const conflictKeys = Object.keys(shortcuts).filter(
|
||||
(k: Hotkey) => shortcuts[k].toLowerCase() === inputValue && k !== key
|
||||
);
|
||||
|
||||
return conflictKeys;
|
||||
}
|
||||
|
||||
type HotkeysSettingsProviderState = {
|
||||
currentHotkeys: Record<string, string>;
|
||||
recordingHotkeys?: any;
|
||||
enabled: boolean;
|
||||
startRecordingHotkeys?: () => void;
|
||||
stopRecordingHotkeys?: () => void;
|
||||
resetRecordingHotkeys?: () => void;
|
||||
changeHotkey?: (key: string, recordedHotkeys: Set<string>) => void;
|
||||
};
|
||||
|
||||
const initialState: HotkeysSettingsProviderState = {
|
||||
currentHotkeys: {},
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export const HotKeysSettingsProviderContext = createContext<
|
||||
HotkeysSettingsProviderState
|
||||
>(initialState);
|
||||
|
||||
const HotKeysSettingsSystemSettings = ({
|
||||
currentHotkeys,
|
||||
enabled,
|
||||
children,
|
||||
}: {
|
||||
currentHotkeys: Record<string, string>;
|
||||
enabled: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
useHotkeys(
|
||||
currentHotkeys.OpenPreferences,
|
||||
() => {
|
||||
document.getElementById("preferences-button")?.click();
|
||||
},
|
||||
{
|
||||
enabled,
|
||||
}
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
currentHotkeys.QuitApp,
|
||||
() => {
|
||||
window.__ENJOY_APP__.app.quit();
|
||||
},
|
||||
{
|
||||
enabled,
|
||||
}
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
currentHotkeys.OpenDevTools,
|
||||
() => {
|
||||
window.__ENJOY_APP__.app.openDevTools();
|
||||
},
|
||||
{
|
||||
enabled,
|
||||
}
|
||||
);
|
||||
return children;
|
||||
};
|
||||
|
||||
export const HotKeysSettingsProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [currentHotkeys, setCurrentHotkeys] = useState<any>(
|
||||
initialState.currentHotkeys
|
||||
);
|
||||
const [keys, { start, stop, resetKeys, isRecording }] = useRecordHotkeys();
|
||||
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
const _hotkeys = await EnjoyApp.settings.getDefaultHotkeys();
|
||||
// During version iterations, there may be added or removed keys.
|
||||
const merged = mergeWithPreference(_hotkeys ?? {}, defaultKeyMap);
|
||||
await EnjoyApp.settings.setDefaultHotkeys(merged).then(() => {
|
||||
setCurrentHotkeys(merged);
|
||||
});
|
||||
};
|
||||
|
||||
const changeHotkey = useCallback(
|
||||
async (
|
||||
keyName: Hotkey,
|
||||
recordedHotkeys: Set<string>
|
||||
): Promise<{
|
||||
error: "conflict" | "invalid";
|
||||
data: string | string[];
|
||||
input: string;
|
||||
} | void> => {
|
||||
const str = [...recordedHotkeys].join("+");
|
||||
const newMap = {
|
||||
...currentHotkeys,
|
||||
[keyName]: str,
|
||||
};
|
||||
|
||||
// validate
|
||||
const conflictKeys = checkKeyAndValue(keyName, str, currentHotkeys);
|
||||
if (conflictKeys.length > 0) {
|
||||
resetKeys();
|
||||
return {
|
||||
error: "conflict",
|
||||
data: conflictKeys,
|
||||
input: str,
|
||||
};
|
||||
}
|
||||
const isValid = isShortcutValid(str);
|
||||
if (!isValid) {
|
||||
resetKeys();
|
||||
return {
|
||||
error: "invalid",
|
||||
data: str,
|
||||
input: str,
|
||||
};
|
||||
}
|
||||
|
||||
await EnjoyApp.settings.setDefaultHotkeys(newMap).then(() => {
|
||||
setCurrentHotkeys(newMap);
|
||||
});
|
||||
resetKeys();
|
||||
},
|
||||
[currentHotkeys]
|
||||
);
|
||||
|
||||
const startRecordingHotkeys = () => {
|
||||
start();
|
||||
};
|
||||
|
||||
const stopRecordingHotkeys = () => {
|
||||
stop();
|
||||
resetKeys();
|
||||
};
|
||||
|
||||
return (
|
||||
<HotKeysSettingsProviderContext.Provider
|
||||
value={{
|
||||
currentHotkeys,
|
||||
recordingHotkeys: keys,
|
||||
enabled: !isRecording,
|
||||
startRecordingHotkeys,
|
||||
stopRecordingHotkeys,
|
||||
resetRecordingHotkeys: resetKeys,
|
||||
changeHotkey,
|
||||
}}
|
||||
>
|
||||
{_.isEmpty(currentHotkeys) ? null : (
|
||||
<HotKeysSettingsSystemSettings
|
||||
{...{
|
||||
currentHotkeys,
|
||||
enabled: !isRecording,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</HotKeysSettingsSystemSettings>
|
||||
)}
|
||||
</HotKeysSettingsProviderContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -4,3 +4,4 @@ export * from "./db-provider";
|
||||
export * from "./theme-provider";
|
||||
export * from "./wavesurfer-provider";
|
||||
export * from "./media-player-provider";
|
||||
export * from './hotkeys-settings-provider'
|
||||
|
||||
2
enjoy/src/types/enjoy-app.d.ts
vendored
2
enjoy/src/types/enjoy-app.d.ts
vendored
@@ -104,6 +104,8 @@ type EnjoyAppType = {
|
||||
) => Promise<void>;
|
||||
getLanguage: () => Promise<string>;
|
||||
switchLanguage: (language: string) => Promise<void>;
|
||||
getDefaultHotkeys: () => Promise<Record<string, string> | undefined>;
|
||||
setDefaultHotkeys: (records: Record<string, string>) => Promise<void>;
|
||||
};
|
||||
fs: {
|
||||
ensureDir: (path: string) => Promise<boolean>;
|
||||
|
||||
Reference in New Issue
Block a user