Compare commits

...

4 Commits

Author SHA1 Message Date
Cameron Fleming ce5fd0fe14
Merge a819739790 into 69168ff062 2025-02-12 01:23:36 +01:00
Brandon Tuttle 69168ff062
Fix fullscreen video relative mouse movements (#85) 2025-02-11 20:00:50 +01:00
Cameron Fleming a819739790 feat(ui): make Ctrl + Alt + Del button a setting
This commit makes the Action Bar Ctrl + Alt + Del button a setting,
which is off by default.
2025-01-04 00:22:08 +00:00
Cameron Fleming 7602aefe98 feat(ui/ActionBar): add Ctrl + Alt + Del button to Action Bar
This commit adds a CTRL + ALT + DEL button to the Action Bar allowing
you to send the combination to the target machine without launching the
Virtual Keyboard, or sending the signal to the computer you're accessing
the KVM from. This is useful for people installing OS', or potentially
debugging kernel issues.
2025-01-04 00:10:56 +00:00
6 changed files with 95 additions and 12 deletions

View File

@ -10,6 +10,7 @@
"dev": "vite dev --mode=development", "dev": "vite dev --mode=development",
"build": "npm run build:prod", "build": "npm run build:prod",
"build:device": "tsc && vite build --mode=device --emptyOutDir", "build:device": "tsc && vite build --mode=device --emptyOutDir",
"dev:device": "vite dev --mode=device",
"build:prod": "tsc && vite build --mode=production", "build:prod": "tsc && vite build --mode=production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
}, },

View File

@ -4,18 +4,21 @@ import {
useMountMediaStore, useMountMediaStore,
useUiStore, useUiStore,
useSettingsStore, useSettingsStore,
useVideoStore,
} from "@/hooks/stores"; } from "@/hooks/stores";
import { MdOutlineContentPasteGo } from "react-icons/md"; import { MdOutlineContentPasteGo } from "react-icons/md";
import Container from "@components/Container"; import Container from "@components/Container";
import { LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu"; import { LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal"; import PasteModal from "@/components/popovers/PasteModal";
import { FaKeyboard } from "react-icons/fa6"; import { FaKeyboard, FaLock } from "react-icons/fa6";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index"; import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import MountPopopover from "./popovers/MountPopover"; import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react"; import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid"; import { CommandLineIcon } from "@heroicons/react/20/solid";
import useKeyboard from "@/hooks/useKeyboard";
import { keys, modifiers } from "@/keyboardMappings";
export default function Actionbar({ export default function Actionbar({
requestFullscreen, requestFullscreen,
@ -33,6 +36,7 @@ export default function Actionbar({
state => state.remoteVirtualMediaState, state => state.remoteVirtualMediaState,
); );
const developerMode = useSettingsStore(state => state.developerMode); const developerMode = useSettingsStore(state => state.developerMode);
const hdmiState = useVideoStore(state => state.hdmiState);
// This is the only way to get a reliable state change for the popover // This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover // at time of writing this there is no mount, or unmount event for the popover
@ -52,6 +56,8 @@ export default function Actionbar({
[setDisableFocusTrap], [setDisableFocusTrap],
); );
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
return ( return (
<Container className="bg-white border-b border-b-slate-800/20 dark:bg-slate-900 dark:border-b-slate-300/20"> <Container className="bg-white border-b border-b-slate-800/20 dark:bg-slate-900 dark:border-b-slate-300/20">
<div <div
@ -203,6 +209,23 @@ export default function Actionbar({
onClick={() => setVirtualKeyboard(!virtualKeyboard)} onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/> />
</div> </div>
{useSettingsStore().actionBarCtrlAltDel && (
<div className="hidden lg:block">
<Button
size="XS"
theme="light"
text="Ctrl + Alt + Del"
LeadingIcon={FaLock}
onClick={() => {
sendKeyboardEvent(
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
}}
/>
</div>
)}
</div> </div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2"> <div className="flex flex-wrap items-center gap-x-2 gap-y-2">
@ -247,6 +270,7 @@ export default function Actionbar({
size="XS" size="XS"
theme="light" theme="light"
text="Fullscreen" text="Fullscreen"
disabled={hdmiState !== 'ready'}
LeadingIcon={LuMaximize} LeadingIcon={LuMaximize}
onClick={() => requestFullscreen()} onClick={() => requestFullscreen()}
/> />

View File

@ -30,6 +30,8 @@ export default function WebRTCVideo() {
const { const {
setClientSize: setVideoClientSize, setClientSize: setVideoClientSize,
setSize: setVideoSize, setSize: setVideoSize,
width: videoWidth,
height: videoHeight,
clientWidth: videoClientWidth, clientWidth: videoClientWidth,
clientHeight: videoClientHeight, clientHeight: videoClientHeight,
} = useVideoStore(); } = useVideoStore();
@ -102,20 +104,43 @@ export default function WebRTCVideo() {
const mouseMoveHandler = useCallback( const mouseMoveHandler = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
if (!videoClientWidth || !videoClientHeight) return; if (!videoClientWidth || !videoClientHeight) return;
const { buttons } = e; // Get the aspect ratios of the video element and the video stream
const videoElementAspectRatio = videoClientWidth / videoClientHeight;
const videoStreamAspectRatio = videoWidth / videoHeight;
// Clamp mouse position within the video boundaries // Calculate the effective video display area
const currMouseX = Math.min(Math.max(1, e.offsetX), videoClientWidth); let effectiveWidth = videoClientWidth;
const currMouseY = Math.min(Math.max(1, e.offsetY), videoClientHeight); let effectiveHeight = videoClientHeight;
let offsetX = 0;
let offsetY = 0;
// Normalize mouse position to 0-32767 range (HID absolute coordinate system) if (videoElementAspectRatio > videoStreamAspectRatio) {
const x = Math.round((currMouseX / videoClientWidth) * 32767); // Pillarboxing: black bars on the left and right
const y = Math.round((currMouseY / videoClientHeight) * 32767); effectiveWidth = videoClientHeight * videoStreamAspectRatio;
offsetX = (videoClientWidth - effectiveWidth) / 2;
} else if (videoElementAspectRatio < videoStreamAspectRatio) {
// Letterboxing: black bars on the top and bottom
effectiveHeight = videoClientWidth / videoStreamAspectRatio;
offsetY = (videoClientHeight - effectiveHeight) / 2;
}
// Clamp mouse position within the effective video boundaries
const clampedX = Math.min(Math.max(offsetX, e.offsetX), offsetX + effectiveWidth);
const clampedY = Math.min(Math.max(offsetY, e.offsetY), offsetY + effectiveHeight);
// Map clamped mouse position to the video stream's coordinate system
const relativeX = (clampedX - offsetX) / effectiveWidth;
const relativeY = (clampedY - offsetY) / effectiveHeight;
// Convert to HID absolute coordinate system (0-32767 range)
const x = Math.round(relativeX * 32767);
const y = Math.round(relativeY * 32767);
// Send mouse movement // Send mouse movement
const { buttons } = e;
sendMouseMovement(x, y, buttons); sendMouseMovement(x, y, buttons);
}, },
[sendMouseMovement, videoClientHeight, videoClientWidth], [sendMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight],
); );
const mouseWheelHandler = useCallback( const mouseWheelHandler = useCallback(

View File

@ -796,6 +796,15 @@ export default function SettingsSidebar() {
}} }}
/> />
</SettingsItem> </SettingsItem>
<SettingsItem
title="Ctrl + Alt + Del Button"
description="Display Ctrl + Alt + Del button on the Action Bar"
>
<Checkbox
checked={settings.actionBarCtrlAltDel}
onChange={e => settings.setActionBarCtrlAltDel(e.target.checked)}
/>
</SettingsItem>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" /> <div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="pb-2 space-y-4"> <div className="pb-2 space-y-4">
<SectionHeader <SectionHeader

View File

@ -270,6 +270,9 @@ interface SettingsState {
// Add new developer mode state // Add new developer mode state
developerMode: boolean; developerMode: boolean;
setDeveloperMode: (enabled: boolean) => void; setDeveloperMode: (enabled: boolean) => void;
actionBarCtrlAltDel: boolean;
setActionBarCtrlAltDel: (enabled: boolean) => void;
} }
export const useSettingsStore = create( export const useSettingsStore = create(
@ -287,6 +290,9 @@ export const useSettingsStore = create(
// Add developer mode with default value // Add developer mode with default value
developerMode: false, developerMode: false,
setDeveloperMode: enabled => set({ developerMode: enabled }), setDeveloperMode: enabled => set({ developerMode: enabled }),
actionBarCtrlAltDel: false,
setActionBarCtrlAltDel: enabled => set({ actionBarCtrlAltDel: enabled }),
}), }),
{ {
name: "settings", name: "settings",

View File

@ -2,13 +2,31 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig(({ mode }) => { declare const process: {
env: {
JETKVM_PROXY_URL: string;
};
};
export default defineConfig(({ mode, command }) => {
const isCloud = mode === "production"; const isCloud = mode === "production";
const onDevice = mode === "device"; const onDevice = mode === "device";
const { JETKVM_PROXY_URL } = process.env;
return { return {
plugins: [tsconfigPaths(), react()], plugins: [tsconfigPaths(), react()],
build: { outDir: isCloud ? "dist" : "../static" }, build: { outDir: isCloud ? "dist" : "../static" },
server: { host: "0.0.0.0" }, server: {
base: onDevice ? "/static" : "/", host: "0.0.0.0",
proxy: JETKVM_PROXY_URL ? {
'/me': JETKVM_PROXY_URL,
'/device': JETKVM_PROXY_URL,
'/webrtc': JETKVM_PROXY_URL,
'/auth': JETKVM_PROXY_URL,
'/storage': JETKVM_PROXY_URL,
'/cloud': JETKVM_PROXY_URL,
} : undefined
},
base: onDevice && command === 'build' ? "/static" : "/",
}; };
}); });