diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl index e91247bac8..a30194e8fb 100644 --- a/gui/public/i18n/en/translation.ftl +++ b/gui/public/i18n/en/translation.ftl @@ -605,6 +605,29 @@ settings-stay_aligned-debug-label = Debugging settings-stay_aligned-debug-description = Please include your settings when reporting problems about Stay Aligned. settings-stay_aligned-debug-copy-label = Copy settings to clipboard +settings-keybinds = Keybind settings +settings-keybinds_ = '' +settings-keybinds-description = Change keybinds for various shortcuts +keybind_config-keybind_name = Keybind +keybind_config-keybind_value = Combination +keybind_config-keybind_delay = Delay before trigger (s) +settings-keybinds_full-reset = Full Reset +settings-keybinds_yaw-reset = Yaw Reset +settings-keybinds_mounting-reset = Mounting Reset +settings-keybinds_feet-mounting-reset = Feet Mounting Reset +settings-keybinds_pause-tracking = Pause Tracking +settings-keybinds_record-keybind = Click to record +settings-keybinds_now-recording = Recording… +settings-keybinds_reset-button = Reset +settings-keybinds_reset-all-button = Reset all +settings-keybinds-wayland-description = You appear to be using wayland, Please change your shortcuts in your system settings. +settings-keybinds-wayland-open-system-settings-button = Open system settings +settings-sidebar-keybinds = Keybinds +settings-keybinds-recorder-modal-title = Assign keybind for +settings-keybinds-recorder-modal-reset-button = Reset +settings-keybinds-recorder-modal-unbind-button = Unbind +settings-keybinds-recorder-modal-done-button = Done + ## FK/Tracking settings settings-general-fk_settings = Tracking settings diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 9674789d43..68d973293c 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -56,6 +56,7 @@ import { QuizSlimeSetQuestion } from './components/onboarding/pages/quiz/SlimeSe import { QuizUsageQuestion } from './components/onboarding/pages/quiz/UsageQuestion'; import { QuizRuntimeQuestion } from './components/onboarding/pages/quiz/RuntimeQuestion'; import { QuizMocapPosQuestion } from './components/onboarding/pages/quiz/MocapPreferencesQuestions'; +import { KeybindSettings } from './components/settings/pages/KeybindSettings'; import { ElectronContextC, provideElectron } from './hooks/electron'; import { AppLocalizationProvider } from './i18n/config'; import { openUrl } from './hooks/crossplatform'; @@ -145,6 +146,7 @@ function Layout() { } /> } /> } /> + } /> void; + error?: string; + } +>(function KeybindRecorder({ keys, onKeysChange, error }) { + const [localKeys, setLocalKeys] = useState(keys); + const [isRecording, setIsRecording] = useState(false); + const [oldKeys, setOldKeys] = useState([]); + const [invalidSlot, setInvalidSlot] = useState(null); + const [errorText, setErrorText] = useState(''); + const inputRef = useRef(null); + const displayKeys = isRecording ? localKeys : keys; + const activeIndex = isRecording ? displayKeys.length : -1; + const displayError = errorText || error; + + const { clearErrors } = useFormContext(); + + const handleKeyDown = (e: React.KeyboardEvent) => { + e.preventDefault(); + const key = e.key.toUpperCase(); + const errorMsg = excludedKeys.includes(key) + ? `Cannot use ${key}!` + : displayKeys.includes(key) + ? `${key} is a Duplicate Key!` + : null; + if (errorMsg) { + setErrorText(errorMsg); + setInvalidSlot(activeIndex); + setTimeout(() => { + setInvalidSlot(null); + }, 350); + return; + } + + if (displayKeys.length < maxKeybindLength) { + const updatedKeys = [...displayKeys, key]; + setLocalKeys(updatedKeys); + onKeysChange(updatedKeys); + if (updatedKeys.length == maxKeybindLength) { + inputRef.current?.blur(); + } + } + }; + + const handleOnBlur = () => { + setIsRecording(false); + if (displayKeys.length < maxKeybindLength - 2 || error) { + onKeysChange(oldKeys); + setLocalKeys(oldKeys); + } + }; + + const handleOnFocus = () => { + clearErrors('keybinds'); + const initialKeys: string[] = []; + setOldKeys(keys); + setLocalKeys(initialKeys); + onKeysChange(initialKeys); + setIsRecording(true); + }; + + return ( +
+
+ +
+ {Array.from({ length: maxKeybindLength }).map((_, i) => { + const key = displayKeys[i]; + const isActive = isRecording && i === activeIndex; + const isInvalid = invalidSlot === i; + return ( +
+
+ {key ?? ''} +
+
+ {i < maxKeybindLength - 1 ? '+' : ''} +
+
+ ); + })} +
+
+ {displayError && ( +
+ {`${errorText} ${error}`} +
+ )} +
+ ); +}); diff --git a/gui/src/components/commons/KeybindRecorderModal.tsx b/gui/src/components/commons/KeybindRecorderModal.tsx new file mode 100644 index 0000000000..e465bf8ed5 --- /dev/null +++ b/gui/src/components/commons/KeybindRecorderModal.tsx @@ -0,0 +1,93 @@ +import { BaseModal } from './BaseModal'; +import { + Controller, + Control, + useFormContext, + FieldValues, + FieldPath, +} from 'react-hook-form'; +import { KeybindRecorder } from './KeybindRecorder'; +import { Typography } from './Typography'; +import { Button } from './Button'; +import './KeybindRow.scss'; +import { useLocalization } from '@fluent/react'; + +export function KeybindRecorderModal({ + id, + control, + name, + isVisisble, + onClose, + onUnbind, + onSubmit, +}: { + id?: string; + control: Control; + name: FieldPath; + isVisisble: boolean; + onClose: () => void; + onUnbind: () => void; + onSubmit: () => void; +}) { + const { l10n } = useLocalization(); + const keybindlocalization = 'settings-keybinds_' + id; + const { + formState: { errors }, + resetField, + handleSubmit, + } = useFormContext(); + + return ( + +
+ + {l10n.getString('settings-keybinds-recorder-modal-title')}{' '} + {l10n.getString(keybindlocalization)} + + ( + + )} + /> +
+
+
+
+
+
+
+
+ ); +} diff --git a/gui/src/components/commons/KeybindRow.scss b/gui/src/components/commons/KeybindRow.scss new file mode 100644 index 0000000000..aac57c22d7 --- /dev/null +++ b/gui/src/components/commons/KeybindRow.scss @@ -0,0 +1,65 @@ +.keybind-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + height: auto; + align-items: center; + gap: 10px; +} + +@keyframes keyslot { + 0%, + 100% { + transform: scale(1); + opacity: 0.6; + } + + 50% { + transform: scale(1.08); + opacity: 1; + } +} + +@keyframes shake { + 0% { + transform: translate(1px, 1px) rotate(0deg); + } + 10% { + transform: translate(-1px, -2px) rotate(-1deg); + } + 20% { + transform: translate(-3px, 0px) rotate(1deg); + } + 30% { + transform: translate(3px, 2px) rotate(0deg); + } + 40% { + transform: translate(1px, -1px) rotate(1deg); + } + 50% { + transform: translate(-1px, 2px) rotate(-1deg); + } + 60% { + transform: translate(-3px, 1px) rotate(0deg); + } + 70% { + transform: translate(3px, 1px) rotate(-1deg); + } + 80% { + transform: translate(-1px, -1px) rotate(1deg); + } + 90% { + transform: translate(1px, 2px) rotate(0deg); + } + 100% { + transform: translate(1px, -2px) rotate(-1deg); + } +} + +.keyslot-animate { + animation: keyslot 1s ease-in-out infinite; +} + +.keyslot-invalid { + animation: shake 0.35s ease; +} diff --git a/gui/src/components/commons/KeybindsRow.tsx b/gui/src/components/commons/KeybindsRow.tsx new file mode 100644 index 0000000000..d4443036d7 --- /dev/null +++ b/gui/src/components/commons/KeybindsRow.tsx @@ -0,0 +1,88 @@ +import { Typography } from './Typography'; +import './KeybindRow.scss'; +import { useEffect, useState } from 'react'; +import { + Control, + FieldPath, + FieldValues, + UseFormGetValues, +} from 'react-hook-form'; +import { NumberSelector } from './NumberSelector'; +import { useLocaleConfig } from '@/i18n/config'; + +function KeyBindKeyList({ keybind }: { keybind: string[] }) { + if (keybind.length <= 1) { + return ( +
+ Click to edit keybind +
+ ); + } + return keybind.map((key, i) => { + return ( +
+
+ {key ?? ''} +
+
+ {i < keybind.length - 1 ? '+' : ''} +
+
+ ); + }); +} + +export function KeybindsRow({ + id, + control, + index, + getValue, + openKeybindRecorderModal, +}: { + id?: string; + control: Control; + index: number; + getValue: UseFormGetValues; + openKeybindRecorderModal: (index: number) => void; +}) { + const [binding, setBinding] = useState(); + const { currentLocales } = useLocaleConfig(); + const secondsFormat = new Intl.NumberFormat(currentLocales, { + style: 'unit', + unit: 'second', + unitDisplay: 'narrow', + maximumFractionDigits: 2, + }); + + const handleOpenModal = () => { + openKeybindRecorderModal(index); + }; + + useEffect(() => { + setBinding(getValue(`keybinds.${index}.binding`)); + }); + + return ( +
+ +
+
+ {binding != null && } +
+
+ } + valueLabelFormat={(value) => secondsFormat.format(value)} + min={0} + max={10} + step={0.2} + /> +
+ ); +} diff --git a/gui/src/components/commons/NumberSelector.tsx b/gui/src/components/commons/NumberSelector.tsx index 93553824bf..4a34af4643 100644 --- a/gui/src/components/commons/NumberSelector.tsx +++ b/gui/src/components/commons/NumberSelector.tsx @@ -57,60 +57,62 @@ export function NumberSelector({ ( -
- {label} -
-
- {doubleStep !== undefined && ( + render={({ field: { onChange, value } }) => { + return ( +
+ {label?.length != 0 ? {label} : <>} +
+
+ {doubleStep !== undefined && ( + + )} - )} - -
-
- {valueLabelFormat ? valueLabelFormat(value) : value} -
-
- - {doubleStep !== undefined && ( +
+
+ {valueLabelFormat ? valueLabelFormat(value) : value} +
+
- )} + {doubleStep !== undefined && ( + + )} +
-
- )} + ); + }} /> ); } diff --git a/gui/src/components/commons/icon/ResetSettingIcon.tsx b/gui/src/components/commons/icon/ResetSettingIcon.tsx new file mode 100644 index 0000000000..a1588e3eac --- /dev/null +++ b/gui/src/components/commons/icon/ResetSettingIcon.tsx @@ -0,0 +1,13 @@ +export function ResetSettingIcon({ size = 24 }: { size?: number }) { + return ( + + + + ); +} diff --git a/gui/src/components/onboarding/UdevRulesModal.tsx b/gui/src/components/onboarding/UdevRulesModal.tsx index b89af1e733..bce273972f 100644 --- a/gui/src/components/onboarding/UdevRulesModal.tsx +++ b/gui/src/components/onboarding/UdevRulesModal.tsx @@ -4,21 +4,19 @@ import { BaseModal } from '@/components/commons/BaseModal'; import { CheckboxInternal } from '@/components/commons/Checkbox'; import { Typography } from '@/components/commons/Typography'; import { useElectron } from '@/hooks/electron'; -import { useWebsocketAPI } from '@/hooks/websocket-api'; -import { RpcMessage, InstalledInfoResponseT } from 'solarxr-protocol'; import { useConfig } from '@/hooks/config'; import { useLocalization } from '@fluent/react'; +import { useAppContext } from '@/hooks/app'; export function UdevRulesModal() { const { config, setConfig } = useConfig(); - const { useRPCPacket, sendRPCPacket } = useWebsocketAPI(); const electron = useElectron(); const [udevContent, setUdevContent] = useState(''); - const [isUdevInstalledResponse, setIsUdevInstalledResponse] = useState(true); const [showUdevWarning, setShowUdevWarning] = useState(false); const [dontShowThisSession, setDontShowThisSession] = useState(false); const [dontShowAgain, setDontShowAgain] = useState(false); const { l10n } = useLocalization(); + const { installInfo } = useAppContext(); const handleUdevContent = async () => { if (electron.isElectron) { @@ -38,28 +36,14 @@ export function UdevRulesModal() { if (!config) throw 'Invalid state!'; if (electron.isElectron) { const isLinux = electron.data().os.type === 'linux'; - const udevMissing = !isUdevInstalledResponse; + const udevMissing = !installInfo?.isUdevInstalled; const notHiddenGlobally = !config.dontShowUdevModal; const notHiddenThisSession = !dontShowThisSession; const shouldShow = isLinux && udevMissing && notHiddenGlobally && notHiddenThisSession; setShowUdevWarning(shouldShow); } - }, [config, isUdevInstalledResponse, dontShowThisSession]); - - useEffect(() => { - sendRPCPacket( - RpcMessage.InstalledInfoRequest, - new InstalledInfoResponseT() - ); - }, []); - - useRPCPacket( - RpcMessage.InstalledInfoResponse, - ({ isUdevInstalled }: InstalledInfoResponseT) => { - setIsUdevInstalledResponse(isUdevInstalled); - } - ); + }, [config, dontShowThisSession]); const handleModalClose = () => { if (!config) throw 'Invalid State!'; @@ -72,7 +56,7 @@ export function UdevRulesModal() { }; return ( - +
diff --git a/gui/src/components/settings/SettingsLayout.tsx b/gui/src/components/settings/SettingsLayout.tsx index 7bfc062ef8..ca148f86ca 100644 --- a/gui/src/components/settings/SettingsLayout.tsx +++ b/gui/src/components/settings/SettingsLayout.tsx @@ -58,6 +58,10 @@ export function SettingSelectorMobile() { label: l10n.getString('settings-sidebar-advanced'), value: { url: '/settings/advanced' }, }, + { + label: l10n.getString('settings-sidebar-keybinds'), + value: { url: '/settings/keybinds' }, + }, { label: l10n.getString('navbar-onboarding'), value: { url: '/onboarding/home' }, diff --git a/gui/src/components/settings/SettingsSidebar.tsx b/gui/src/components/settings/SettingsSidebar.tsx index 1095073394..4a9837759f 100644 --- a/gui/src/components/settings/SettingsSidebar.tsx +++ b/gui/src/components/settings/SettingsSidebar.tsx @@ -73,6 +73,13 @@ export function SettingsSidebar() { scrollTo="gestureControl" id="settings-sidebar-gesture_control" /> + { + + }
diff --git a/gui/src/components/settings/pages/KeybindSettings.scss b/gui/src/components/settings/pages/KeybindSettings.scss new file mode 100644 index 0000000000..b73610c1a0 --- /dev/null +++ b/gui/src/components/settings/pages/KeybindSettings.scss @@ -0,0 +1,15 @@ +.keybind-settings { + display: grid; + + grid-template: + 'n v d ' auto + 'k k k ' auto + 'b . . ' auto + / 1fr 1fr 90px; + + grid-template-columns: subgrid; + grid-template-rows: subgrid; + + align-items: left; + gap: 10px; +} diff --git a/gui/src/components/settings/pages/KeybindSettings.tsx b/gui/src/components/settings/pages/KeybindSettings.tsx new file mode 100644 index 0000000000..f9b2ba5fb7 --- /dev/null +++ b/gui/src/components/settings/pages/KeybindSettings.tsx @@ -0,0 +1,269 @@ +import { KeybindRecorderModal } from '@/components/commons/KeybindRecorderModal'; +import { + SettingsPageLayout, + SettingsPagePaneLayout, +} from '@/components/settings/SettingsPageLayout'; +import { WrenchIcon } from '@/components/commons/icon/WrenchIcons'; +import { Typography } from '@/components/commons/Typography'; +import { useLocalization } from '@fluent/react'; +import './KeybindSettings.scss'; +import { Button } from '@/components/commons/Button'; +import { KeybindsRow } from '@/components/commons/KeybindsRow'; +import { useWebsocketAPI } from '@/hooks/websocket-api'; +import { ReactNode, useEffect, useRef, useState } from 'react'; +import { + ChangeKeybindRequestT, + KeybindRequestT, + KeybindResponseT, + KeybindT, + OpenUriRequestT, + RpcMessage, +} from 'solarxr-protocol'; +import { + FieldPath, + FormProvider, + useFieldArray, + useForm, +} from 'react-hook-form'; +import { useAppContext } from '@/hooks/app'; +import { useElectron } from '@/hooks/electron'; + +export type KeybindForm = { + keybinds: { + id: number; + name: string; + binding: string[]; + delay: number; + }[]; +}; + +export function KeybindSettings() { + const electron = useElectron(); + const { l10n } = useLocalization(); + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + const [isOpen, setIsOpen] = useState(false); + const [defaultKeybindsState, setDefaultKeybindsState] = useState( + { + keybinds: [], + } + ); + const currentIndex = useRef(null); + const { installInfo } = useAppContext(); + + const methods = useForm({ + defaultValues: defaultKeybindsState, + }); + + const { + control, + handleSubmit, + reset, + setValue, + getValues, + setError, + clearErrors, + resetField, + } = methods; + + const { fields } = useFieldArray({ + control, + name: 'keybinds', + }); + + const onSubmit = () => { + const value = getValues(); + if (checkDuplicates(value)) { + return; + } + clearErrors('keybinds'); + + value.keybinds.forEach((kb) => { + const changeKeybindRequest = new ChangeKeybindRequestT(); + + const keybind = new KeybindT(); + keybind.keybindId = kb.id; + keybind.keybindNameId = kb.name; + keybind.keybindValue = kb.binding.join('+'); + keybind.keybindDelay = kb.delay; + + changeKeybindRequest.keybind = keybind; + + sendRPCPacket(RpcMessage.ChangeKeybindRequest, changeKeybindRequest); + setIsOpen(false); + }); + }; + + const checkDuplicates = (value: KeybindForm) => { + const normalized = value.keybinds + .filter((kb) => kb.binding.length > 0) + .map((kb) => JSON.stringify([...kb.binding].sort())); + + const unique = new Set(normalized); + + if (unique.size !== normalized.length) { + setError('keybinds', { + type: 'manual', + message: 'Duplicate keybind combinations are not allowed', + }); + return true; + } + + return false; + }; + + const handleOpenSystemSettingsButton = () => { + sendRPCPacket(RpcMessage.OpenUriRequest, new OpenUriRequestT()); + }; + + useRPCPacket( + RpcMessage.KeybindResponse, + ({ keybind, defaultKeybinds }: KeybindResponseT) => { + if (!keybind) return; + + const mappedDefaults = defaultKeybinds.map((kb) => ({ + id: kb.keybindId, + name: typeof kb.keybindNameId === 'string' ? kb.keybindNameId : '', + binding: + typeof kb.keybindValue === 'string' ? kb.keybindValue.split('+') : [], + delay: kb.keybindDelay, + })); + + setDefaultKeybindsState({ keybinds: mappedDefaults }); + reset({ keybinds: mappedDefaults }); + + const mapped = keybind.map((kb) => ({ + id: kb.keybindId, + name: typeof kb.keybindNameId === 'string' ? kb.keybindNameId : '', + binding: + typeof kb.keybindValue === 'string' ? kb.keybindValue.split('+') : [], + delay: kb.keybindDelay, + })); + + mapped.forEach((keybind, index) => { + setValue(`keybinds.${index}.binding`, keybind.binding); + setValue(`keybinds.${index}.delay`, keybind.delay); + }); + } + ); + + const handleOpenRecorderModal = (index: number) => { + currentIndex.current = index; + if (currentIndex !== null) { + setIsOpen(true); + } + }; + + const onClose = () => { + if (currentIndex.current != null) { + resetField(`keybinds.${currentIndex.current}.binding`); + } + setIsOpen(false); + }; + + const createKeybindRows = (): ReactNode => { + return fields.map((field, index) => { + return ( +
+ +
+ ); + }); + }; + + useEffect(() => { + sendRPCPacket(RpcMessage.KeybindRequest, new KeybindRequestT()); + }, []); + + return ( + + } id="keybinds"> +
+ +
+ {l10n + .getString('settings-keybinds-description') + .split('\n') + .map((line, i) => ( + {line} + ))} +
+ {!installInfo?.isWayland ? ( +
+ +
+
+
+ ) : ( + electron.isElectron && + electron.data().os.type !== 'windows' && ( + <> + +
+ + + + {createKeybindRows()} +
+
+
+ + } + isVisisble={isOpen} + onClose={onClose} + onUnbind={() => { + if (currentIndex.current != null) + setValue( + `keybinds.${currentIndex.current}.binding`, + [] + ); + }} + onSubmit={onSubmit} + /> +
+ + ) + )} +
+
+
+ ); +} diff --git a/gui/src/hooks/app.ts b/gui/src/hooks/app.ts index 0623db19bb..7b0b58b3e0 100644 --- a/gui/src/hooks/app.ts +++ b/gui/src/hooks/app.ts @@ -5,6 +5,7 @@ import { ResetResponseT, RpcMessage, StartDataFeedT, + InstalledInfoResponseT, } from 'solarxr-protocol'; import { handleResetSounds } from '@/sounds/sounds'; import { useConfig } from './config'; @@ -18,11 +19,17 @@ import { DEFAULT_LOCALE, LangContext } from '@/i18n/config'; export interface AppContext { currentFirmwareRelease: FirmwareRelease | null; + installInfo: InstalledInfoResponseT | null; } export function useProvideAppContext(): AppContext { - const { useRPCPacket, sendDataFeedPacket, useDataFeedPacket, isConnected } = - useWebsocketAPI(); + const { + useRPCPacket, + sendRPCPacket, + sendDataFeedPacket, + useDataFeedPacket, + isConnected, + } = useWebsocketAPI(); const { changeLocales } = useContext(LangContext); const { config } = useConfig(); const { dataFeedConfig } = useDataFeedConfig(); @@ -34,6 +41,8 @@ export function useProvideAppContext(): AppContext { const [currentFirmwareRelease, setCurrentFirmwareRelease] = useState(null); + const [installInfo, setInstallInfo] = useState(null); + useEffect(() => { if (isConnected) { const startDataFeed = new StartDataFeedT(); @@ -70,6 +79,17 @@ export function useProvideAppContext(): AppContext { }; }, [config?.uuid]); + useEffect(() => { + sendRPCPacket(RpcMessage.InstalledInfoRequest, new InstalledInfoResponseT()); + }, []); + + useRPCPacket( + RpcMessage.InstalledInfoResponse, + ({ isUdevInstalled, isWayland }: InstalledInfoResponseT) => { + setInstallInfo(new InstalledInfoResponseT(isUdevInstalled, isWayland)); + } + ); + useLayoutEffect(() => { changeLocales([config?.lang || DEFAULT_LOCALE]); }, []); @@ -85,6 +105,7 @@ export function useProvideAppContext(): AppContext { return { currentFirmwareRelease, + installInfo, }; } diff --git a/gui/tailwind.config.ts b/gui/tailwind.config.ts index 07004e88cd..26c3b00a58 100644 --- a/gui/tailwind.config.ts +++ b/gui/tailwind.config.ts @@ -183,6 +183,7 @@ const config = { lg: '1300px', xl: '1600px', tall: { raw: '(min-height: 860px)' }, + 'keybinds-small': { raw: 'not (min-width: 1230px)' }, }, extend: { colors: { diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index 6e213498b3..2f84826f7d 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -83,6 +83,8 @@ dependencies { implementation("com.mayakapps.kache:kache:2.1.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + implementation("com.github.HannahPadd:DbusGlobalShortcutsWayland:v0.1.0") + api("com.github.loucass003:EspflashKotlin:v0.11.0") // Allow the use of reflection diff --git a/server/core/src/main/java/dev/slimevr/Keybinding.kt b/server/core/src/main/java/dev/slimevr/Keybinding.kt index 9f20f768a8..f0d246a963 100644 --- a/server/core/src/main/java/dev/slimevr/Keybinding.kt +++ b/server/core/src/main/java/dev/slimevr/Keybinding.kt @@ -2,51 +2,49 @@ package dev.slimevr import com.melloware.jintellitype.HotkeyListener import com.melloware.jintellitype.JIntellitype +import dev.hannah.portals.PortalManager +import dev.hannah.portals.globalShortcuts.Shortcut +import dev.hannah.portals.globalShortcuts.ShortcutTuple import dev.slimevr.config.KeybindingsConfig import dev.slimevr.tracking.trackers.TrackerUtils import io.eiren.util.OperatingSystem import io.eiren.util.OperatingSystem.Companion.currentPlatform import io.eiren.util.ann.AWTThread import io.eiren.util.logging.LogManager +import solarxr_protocol.rpc.KeybindId + +enum class Keybinds(val id: Int, val keybindName: String, val description: String, val keybind: String) { + FULL_RESET(KeybindId.FULL_RESET, "FULL_RESET", "Full Reset", "CTRL+ALT+SHIFT+Y"), + MOUNTING_RESET(KeybindId.MOUNTING_RESET, "MOUNTING_RESET", "Mounting Reset", "CTRL+ALT+SHIFT+I"), + PAUSE_TRACKING(KeybindId.PAUSE_TRACKING, "PAUSE_TRACKING", "Pause Tracking", "CTRL+ALT+SHIFT+O"), + YAW_RESET(KeybindId.YAW_RESET, "YAW_RESET", "Yaw Reset", "CTRL+ALT+SHIFT+U"), + FEET_MOUNTING_RESET(KeybindId.FEET_MOUNTING_RESET, "FEET_MOUNTING_RESET", "Feet Mounting Reset", "CTRL+ALT+SHIFT+P"), + ; + + override fun toString(): String = keybindName + + companion object { + private val byId = Keybinds.entries.associateBy { it.id } + private val byName = Keybinds.entries.associateBy { it.keybindName } + + fun getById(value: Int): Keybinds? = byId[value] + fun getByName(name: String): Keybinds? = byName[name] + } +} class Keybinding @AWTThread constructor(val server: VRServer) : HotkeyListener { val config: KeybindingsConfig = server.configManager.vrConfig.keybindings init { - if (currentPlatform != OperatingSystem.WINDOWS) { - LogManager - .info( - "[Keybinding] Currently only supported on Windows. Keybindings will be disabled.", - ) - } else { + if (currentPlatform == OperatingSystem.WINDOWS) { try { if (JIntellitype.getInstance() != null) { JIntellitype.getInstance().addHotKeyListener(this) - val fullResetBinding = config.fullResetBinding - JIntellitype.getInstance() - .registerHotKey(FULL_RESET, fullResetBinding) - LogManager.info("[Keybinding] Bound full reset to $fullResetBinding") - - val yawResetBinding = config.yawResetBinding - JIntellitype.getInstance() - .registerHotKey(YAW_RESET, yawResetBinding) - LogManager.info("[Keybinding] Bound yaw reset to $yawResetBinding") - - val mountingResetBinding = config.mountingResetBinding - JIntellitype.getInstance() - .registerHotKey(MOUNTING_RESET, mountingResetBinding) - LogManager.info("[Keybinding] Bound reset mounting to $mountingResetBinding") - - val feetMountingResetBinding = config.feetMountingResetBinding - JIntellitype.getInstance() - .registerHotKey(FEET_MOUNTING_RESET, feetMountingResetBinding) - LogManager.info("[Keybinding] Bound feet reset mounting to $feetMountingResetBinding") - - val pauseTrackingBinding = config.pauseTrackingBinding - JIntellitype.getInstance() - .registerHotKey(PAUSE_TRACKING, pauseTrackingBinding) - LogManager.info("[Keybinding] Bound pause tracking to $pauseTrackingBinding") + config.keybinds.forEach { (i, keybind) -> + JIntellitype.getInstance() + .registerHotKey(keybind.id, keybind.binding) + } } } catch (e: Throwable) { LogManager @@ -55,42 +53,97 @@ class Keybinding @AWTThread constructor(val server: VRServer) : HotkeyListener { ) } } - } + if (currentPlatform == OperatingSystem.LINUX) { + val portalManager = PortalManager(SLIMEVR_IDENTIFIER) + val shortcutsList = Keybinds.entries.map { + ShortcutTuple(it.name, Shortcut(it.description, it.keybind).shortcut) + }.toMutableList() - @AWTThread - override fun onHotKey(identifier: Int) { - when (identifier) { - FULL_RESET -> server.scheduleResetTrackersFull(RESET_SOURCE_NAME, config.fullResetDelay) + val globalShortcutsHandler = portalManager.globalShortcutsRequest(shortcutsList) + Runtime.getRuntime().addShutdownHook( + Thread { + println("Closing connection") + globalShortcutsHandler.close() + }, + ) - YAW_RESET -> server.scheduleResetTrackersYaw(RESET_SOURCE_NAME, config.yawResetDelay) + val onShortcut: (id: String) -> Unit = { shortcutId -> + val keybind = Keybinds.getByName(shortcutId) + if (keybind != null) { + val delay = config.keybinds[keybind]?.delay?.toLong() ?: 0L + when (keybind) { + Keybinds.FULL_RESET -> { + server.scheduleResetTrackersFull(keybind.keybindName, delay) + } - MOUNTING_RESET -> server.scheduleResetTrackersMounting( - RESET_SOURCE_NAME, - config.mountingResetDelay, - ) + Keybinds.YAW_RESET -> { + server.scheduleResetTrackersYaw(keybind.keybindName, delay) + } - FEET_MOUNTING_RESET -> server.scheduleResetTrackersMounting( - RESET_SOURCE_NAME, - config.feetMountingResetDelay, - TrackerUtils.feetsBodyParts, - ) + Keybinds.MOUNTING_RESET -> { + server.scheduleResetTrackersMounting( + keybind.keybindName, + delay, + ) + } - PAUSE_TRACKING -> - server - .scheduleTogglePauseTracking( - RESET_SOURCE_NAME, - config.pauseTrackingDelay, - ) + Keybinds.FEET_MOUNTING_RESET -> { + server.scheduleResetTrackersMounting( + keybind.keybindName, + delay, + TrackerUtils.feetsBodyParts, + ) + } + + Keybinds.PAUSE_TRACKING -> { + server.scheduleTogglePauseTracking( + keybind.keybindName, + delay, + ) + } + } + } + } + + globalShortcutsHandler.onShortcutActivated = onShortcut } } - companion object { - private const val RESET_SOURCE_NAME = "Keybinding" + @AWTThread + override fun onHotKey(identifier: Int) { + val keybind = Keybinds.getById(identifier) ?: return + val delay = config.keybinds[keybind]?.delay?.toLong() ?: 0L + when (keybind) { + Keybinds.FULL_RESET -> { + server.scheduleResetTrackersFull(keybind.keybindName, delay) + } + + Keybinds.YAW_RESET -> { + server.scheduleResetTrackersYaw(keybind.keybindName, delay) + } - private const val FULL_RESET = 1 - private const val YAW_RESET = 2 - private const val MOUNTING_RESET = 3 - private const val FEET_MOUNTING_RESET = 4 - private const val PAUSE_TRACKING = 5 + Keybinds.MOUNTING_RESET -> { + server.scheduleResetTrackersMounting( + keybind.keybindName, + delay, + TrackerUtils.feetsBodyParts, + ) + } + + Keybinds.FEET_MOUNTING_RESET -> { + server.scheduleResetTrackersMounting( + keybind.keybindName, + delay, + TrackerUtils.feetsBodyParts, + ) + } + + Keybinds.PAUSE_TRACKING -> { + server.scheduleTogglePauseTracking( + keybind.keybindName, + delay, + ) + } + } } } diff --git a/server/core/src/main/java/dev/slimevr/VRServer.kt b/server/core/src/main/java/dev/slimevr/VRServer.kt index 074631cc6e..4f5806fd86 100644 --- a/server/core/src/main/java/dev/slimevr/VRServer.kt +++ b/server/core/src/main/java/dev/slimevr/VRServer.kt @@ -11,6 +11,7 @@ import dev.slimevr.games.vrchat.VRCConfigHandler import dev.slimevr.games.vrchat.VRCConfigHandlerStub import dev.slimevr.games.vrchat.VRChatConfigManager import dev.slimevr.guards.ServerGuards +import dev.slimevr.keybind.KeybindHandler import dev.slimevr.osc.OSCHandler import dev.slimevr.osc.OSCRouter import dev.slimevr.osc.VMCHandler @@ -128,6 +129,8 @@ class VRServer @JvmOverloads constructor( val networkProfileChecker: NetworkProfileChecker + val keybindHandler: KeybindHandler + val serverGuards = ServerGuards() init { @@ -144,6 +147,7 @@ class VRServer @JvmOverloads constructor( vrcConfigManager = VRChatConfigManager(this, vrcConfigHandlerProvider(this)) networkProfileChecker = networkProfileProvider(this) trackingChecklistManager = TrackingChecklistManager(this) + keybindHandler = KeybindHandler(this) protocolAPI = ProtocolAPI(this) val computedTrackers = humanPoseManager.computedTrackers diff --git a/server/core/src/main/java/dev/slimevr/config/KeybindingsConfig.java b/server/core/src/main/java/dev/slimevr/config/KeybindingsConfig.java deleted file mode 100644 index a3c1b0bfe3..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/KeybindingsConfig.java +++ /dev/null @@ -1,88 +0,0 @@ -package dev.slimevr.config; - -public class KeybindingsConfig { - - private String fullResetBinding = "CTRL+ALT+SHIFT+Y"; - - private String yawResetBinding = "CTRL+ALT+SHIFT+U"; - - private String mountingResetBinding = "CTRL+ALT+SHIFT+I"; - - private String feetMountingResetBinding = "CTRL+ALT+SHIFT+P"; - - private String pauseTrackingBinding = "CTRL+ALT+SHIFT+O"; - - private long fullResetDelay = 0L; - - private long yawResetDelay = 0L; - - private long mountingResetDelay = 0L; - - private long feetMountingResetDelay = 0L; - - private long pauseTrackingDelay = 0L; - - - public KeybindingsConfig() { - } - - public String getFullResetBinding() { - return fullResetBinding; - } - - public String getYawResetBinding() { - return yawResetBinding; - } - - public String getMountingResetBinding() { - return mountingResetBinding; - } - - public String getFeetMountingResetBinding() { - return feetMountingResetBinding; - } - - public String getPauseTrackingBinding() { - return pauseTrackingBinding; - } - - public long getFullResetDelay() { - return fullResetDelay; - } - - public void setFullResetDelay(long delay) { - fullResetDelay = delay; - } - - public long getYawResetDelay() { - return yawResetDelay; - } - - public void setYawResetDelay(long delay) { - yawResetDelay = delay; - } - - public long getMountingResetDelay() { - return mountingResetDelay; - } - - public void setMountingResetDelay(long delay) { - mountingResetDelay = delay; - } - - public long getFeetMountingResetDelay() { - return feetMountingResetDelay; - } - - public void setFeetMountingResetDelay(long delay) { - feetMountingResetDelay = delay; - } - - public long getPauseTrackingDelay() { - return pauseTrackingDelay; - } - - public void setPauseTrackingDelay(long delay) { - pauseTrackingDelay = delay; - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/KeybindingsConfig.kt b/server/core/src/main/java/dev/slimevr/config/KeybindingsConfig.kt new file mode 100644 index 0000000000..3505500096 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/KeybindingsConfig.kt @@ -0,0 +1,16 @@ +package dev.slimevr.config + +import dev.slimevr.Keybinds + +data class KeybindData( + var id: Int, + var name: String, + var binding: String, + var delay: Float, +) + +class KeybindingsConfig { + val keybinds: MutableMap = Keybinds.entries + .associateWith { KeybindData(it.id, it.keybindName, it.keybind, 0f) } + .toMutableMap() +} diff --git a/server/core/src/main/java/dev/slimevr/keybind/KeybindHandler.kt b/server/core/src/main/java/dev/slimevr/keybind/KeybindHandler.kt new file mode 100644 index 0000000000..6fbb4bb2cf --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/keybind/KeybindHandler.kt @@ -0,0 +1,55 @@ +package dev.slimevr.keybind + +import dev.slimevr.VRServer +import dev.slimevr.config.KeybindingsConfig +import solarxr_protocol.rpc.KeybindT +import java.util.concurrent.CopyOnWriteArrayList + +class KeybindHandler(val vrServer: VRServer) { + private val listeners: MutableList = CopyOnWriteArrayList() + var keybinds: MutableList = mutableListOf() + var defaultKeybinds: MutableList = mutableListOf() + + init { + createKeybinds() + } + + fun addListener(listener: KeybindListener) { + this.listeners.add(listener) + } + + fun removeListener(listener: KeybindListener) { + listeners.removeIf { listener == it } + } + + private fun createKeybinds() { + keybinds.clear() + defaultKeybinds.clear() + vrServer.configManager.vrConfig.keybindings.keybinds.forEach { (i, keybind) -> + keybinds.add( + KeybindT().apply { + keybindId = keybind.id + keybindNameId = keybind.name + keybindValue = keybind.binding + keybindDelay = keybind.delay + }, + ) + } + + val binds = KeybindingsConfig().keybinds + binds.forEach { (i, keybind) -> + defaultKeybinds.add( + KeybindT().apply { + keybindId = keybind.id + keybindNameId = keybind.name + keybindValue = keybind.binding + keybindDelay = keybind.delay + }, + ) + } + } + + fun updateKeybinds() { + createKeybinds() + } +} diff --git a/server/core/src/main/java/dev/slimevr/keybind/KeybindListener.kt b/server/core/src/main/java/dev/slimevr/keybind/KeybindListener.kt new file mode 100644 index 0000000000..0f4e9ade58 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/keybind/KeybindListener.kt @@ -0,0 +1,6 @@ +package dev.slimevr.keybind + +interface KeybindListener { + fun sendKeybind() + fun onKeybindUpdate() +} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt index 57be55772a..c0d9b4c8a7 100644 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt +++ b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt @@ -11,6 +11,8 @@ import dev.slimevr.protocol.rpc.autobone.RPCAutoBoneHandler import dev.slimevr.protocol.rpc.firmware.RPCFirmwareUpdateHandler import dev.slimevr.protocol.rpc.games.vrchat.RPCVRChatHandler import dev.slimevr.protocol.rpc.installinfo.RPCInstallInfoHandler +import dev.slimevr.protocol.rpc.keybinds.RPCKeybindHandler +import dev.slimevr.protocol.rpc.openuri.RPCOpenUriHandler import dev.slimevr.protocol.rpc.reset.RPCResetHandler import dev.slimevr.protocol.rpc.serial.RPCProvisioningHandler import dev.slimevr.protocol.rpc.serial.RPCSerialHandler @@ -54,6 +56,14 @@ class RPCHandler(private val api: ProtocolAPI) : ProtocolHandler { allprojects { repositories { // Use jcenter for resolving dependencies. - // You can declare any Maven/Ivy/file repository here. mavenCentral() maven(url = "https://jitpack.io") maven(url = "https://oss.sonatype.org/content/repositories/snapshots") @@ -74,7 +73,7 @@ tasks.shadowJar { exclude(dependency("com.fazecast:jSerialComm:.*")) exclude(dependency("net.java.dev.jna:.*:.*")) exclude(dependency("com.google.flatbuffers:flatbuffers-java:.*")) - + exclude(dependency("com.github.HannahPadd:DbusGlobalShortcutsWayland:v0.1.0")) exclude(project(":solarxr-protocol")) } archiveBaseName.set("slimevr")