Compare commits

..

1 Commits

Author SHA1 Message Date
Marc Brooks e5f58332b7
Merge 1e57f4bf4f into 608f69db13 2025-08-14 04:17:07 +00:00
8 changed files with 175 additions and 256 deletions

View File

@ -133,7 +133,7 @@ export default function InfoBar() {
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
keyboardLedState.caps_lock keyboardLedState?.caps_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
@ -143,7 +143,7 @@ export default function InfoBar() {
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
keyboardLedState.num_lock keyboardLedState?.num_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
@ -153,24 +153,24 @@ export default function InfoBar() {
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
keyboardLedState.scroll_lock keyboardLedState?.scroll_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
> >
Scroll Lock Scroll Lock
</div> </div>
{keyboardLedState.compose ? ( {keyboardLedState?.compose ? (
<div className="shrink-0 p-1 px-1.5 text-xs"> <div className="shrink-0 p-1 px-1.5 text-xs">
Compose Compose
</div> </div>
) : null} ) : null}
{keyboardLedState.kana ? ( {keyboardLedState?.kana ? (
<div className="shrink-0 p-1 px-1.5 text-xs"> <div className="shrink-0 p-1 px-1.5 text-xs">
Kana Kana
</div> </div>
) : null} ) : null}
{keyboardLedState.shift ? ( {keyboardLedState?.shift ? (
<div className="shrink-0 p-1 px-1.5 text-xs"> <div className="shrink-0 p-1 px-1.5 text-xs">
Shift Shift
</div> </div>

View File

@ -1,3 +1,4 @@
import { useShallow } from "zustand/react/shallow";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
@ -12,7 +13,7 @@ import "react-simple-keyboard/build/css/index.css";
import AttachIconRaw from "@/assets/attach-icon.svg"; import AttachIconRaw from "@/assets/attach-icon.svg";
import DetachIconRaw from "@/assets/detach-icon.svg"; import DetachIconRaw from "@/assets/detach-icon.svg";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { HidState, useHidStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings"; import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
@ -25,7 +26,7 @@ const AttachIcon = ({ className }: { className?: string }) => {
}; };
function KeyboardWrapper() { function KeyboardWrapper() {
const [layoutName] = useState("default"); const [layoutName, setLayoutName] = useState("default");
const keyboardRef = useRef<HTMLDivElement>(null); const keyboardRef = useRef<HTMLDivElement>(null);
const showAttachedVirtualKeyboard = useUiStore( const showAttachedVirtualKeyboard = useUiStore(
@ -35,16 +36,14 @@ function KeyboardWrapper() {
state => state.setAttachedVirtualKeyboardVisibility, state => state.setAttachedVirtualKeyboardVisibility,
); );
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
const keysDownState = useHidStore((state: HidState) => state.keysDownState);
const { handleKeyPress, sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 }); const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
/* /*
// These will be used to display the currently pressed keys and modifiers on the virtual keyboard // These will be used to display the currently pressed keys and modifiers on the virtual keyboard
@ -130,55 +129,74 @@ function KeyboardWrapper() {
const onKeyDown = useCallback( const onKeyDown = useCallback(
(key: string) => { (key: string) => {
const latchingKeys = ["CapsLock", "ScrollLock", "NumLock", "Meta", "Compose", "Kana"]; const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight";
const dynamicKeys = ["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight"]; const isKeyCaps = key === "CapsLock";
const cleanKey = key.replace(/[()]/g, "");
const keyHasShiftModifier = key.includes("(");
// Handle toggle of layout for shift or caps lock
const toggleLayout = () => {
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
};
if (key === "CtrlAltDelete") { if (key === "CtrlAltDelete") {
sendKeyboardEvent({ keys: [keys.Delete], modifier: modifiers.ControlLeft | modifiers.AltLeft }); sendKeyboardEvent(
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100); setTimeout(resetKeyboardState, 100);
return; return;
} }
if (key === "AltMetaEscape") { if (key === "AltMetaEscape") {
sendKeyboardEvent({ keys: [keys.Escape], modifier: modifiers.AltLeft | modifiers.MetaLeft }); sendKeyboardEvent(
[keys["Escape"]],
[modifiers["MetaLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100); setTimeout(resetKeyboardState, 100);
return; return;
} }
if (key === "CtrlAltBackspace") { if (key === "CtrlAltBackspace") {
sendKeyboardEvent({ keys: [keys.Backspace], modifier: modifiers.ControlLeft | modifiers.AltLeft }); sendKeyboardEvent(
[keys["Backspace"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100); setTimeout(resetKeyboardState, 100);
return; return;
} }
// if they press any of the latching keys, we send a keypress down event and the release it automatically (on timer) if (isKeyShift || isKeyCaps) {
if (latchingKeys.includes(key)) { toggleLayout();
handleKeyPress(keys[key], true)
setTimeout(() => handleKeyPress(keys[key], false), 100); if (isCapsLockActive) {
return; sendKeyboardEvent([keys["CapsLock"]], []);
return;
}
} }
// if they press any of the dynamic keys, we send a keypress down event but we don't release it until they click it again // Collect new active keys and modifiers
if (dynamicKeys.includes(key)) { const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
const currentlyDown = keysDownState.keys.includes(keys[key]); const newModifiers =
handleKeyPress(keys[key], !currentlyDown) keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
return;
// Update current keys and modifiers
sendKeyboardEvent(newKeys, newModifiers);
// If shift was used as a modifier and caps lock is not active, revert to default layout
if (keyHasShiftModifier && !isCapsLockActive) {
setLayoutName("default");
} }
// otherwise, just treat it as a down+up pair setTimeout(resetKeyboardState, 100);
const cleanKey = key.replace(/[()]/g, "");
handleKeyPress(keys[cleanKey], true);
setTimeout(() => handleKeyPress(keys[cleanKey], false), 50);
}, },
[handleKeyPress, sendKeyboardEvent, resetKeyboardState, keysDownState], [isCapsLockActive, sendKeyboardEvent, resetKeyboardState],
); );
// TODO handle the display of down keys and the layout change for shift/caps lock const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
// const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState.caps_lock)); const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
// // Handle toggle of layout for shift or caps lock
// const toggleLayout = () => {
// setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
// };
return ( return (
<div <div

View File

@ -37,7 +37,7 @@ export default function WebRTCVideo() {
const [isPointerLockActive, setIsPointerLockActive] = useState(false); const [isPointerLockActive, setIsPointerLockActive] = useState(false);
// Store hooks // Store hooks
const settings = useSettingsStore(); const settings = useSettingsStore();
const { handleKeyPress, resetKeyboardState } = useKeyboard(); const { sendKeypressEvent, resetKeyboardState } = useKeyboard();
const setMousePosition = useMouseStore((state: MouseState) => state.setMousePosition); const setMousePosition = useMouseStore((state: MouseState) => state.setMousePosition);
const setMouseMove = useMouseStore((state: MouseState) => state.setMouseMove); const setMouseMove = useMouseStore((state: MouseState) => state.setMouseMove);
const { const {
@ -55,14 +55,13 @@ export default function WebRTCVideo() {
const videoContrast = useSettingsStore((state: SettingsState) => state.videoContrast); const videoContrast = useSettingsStore((state: SettingsState) => state.videoContrast);
// RTC related states // RTC related states
const peerConnection = useRTCStore((state: RTCState) => state.peerConnection); const peerConnection = useRTCStore((state: RTCState ) => state.peerConnection);
// HDMI and UI states // HDMI and UI states
const hdmiState = useVideoStore((state: VideoState) => state.hdmiState); const hdmiState = useVideoStore((state: VideoState) => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isVideoLoading = !isPlaying; const isVideoLoading = !isPlaying;
// Mouse wheel states
const [blockWheelEvent, setBlockWheelEvent] = useState(false); const [blockWheelEvent, setBlockWheelEvent] = useState(false);
// Misc states and hooks // Misc states and hooks
@ -105,7 +104,7 @@ export default function WebRTCVideo() {
// Pointer lock and keyboard lock related // Pointer lock and keyboard lock related
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost"; const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
const isFullscreenEnabled = document.fullscreenEnabled; const isFullscreenEnabled = document.fullscreenEnabled;
const checkNavigatorPermissions = useCallback(async (permissionName: string) => { const checkNavigatorPermissions = useCallback(async (permissionName: string) => {
if (!navigator.permissions || !navigator.permissions.query) { if (!navigator.permissions || !navigator.permissions.query) {
return false; // if can't query permissions, assume NOT granted return false; // if can't query permissions, assume NOT granted
@ -141,11 +140,11 @@ export default function WebRTCVideo() {
if (videoElm.current === null) return; if (videoElm.current === null) return;
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock"); const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
if (isKeyboardLockGranted && "keyboard" in navigator) { if (isKeyboardLockGranted && "keyboard" in navigator) {
try { try {
// @ts-expect-error - keyboard lock is not supported in all browsers // @ts-expect-error - keyboard lock is not supported in all browsers
await navigator.keyboard.lock(); await navigator.keyboard.lock();
} catch { } catch {
// ignore errors // ignore errors
} }
@ -156,12 +155,12 @@ export default function WebRTCVideo() {
if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return; if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return;
if ("keyboard" in navigator) { if ("keyboard" in navigator) {
try { try {
// @ts-expect-error - keyboard unlock is not supported in all browsers // @ts-expect-error - keyboard unlock is not supported in all browsers
await navigator.keyboard.unlock(); await navigator.keyboard.unlock();
} catch { } catch {
// ignore errors // ignore errors
} }
} }
}, []); }, []);
@ -189,7 +188,7 @@ export default function WebRTCVideo() {
}, [isPointerLockPossible]); }, [isPointerLockPossible]);
const requestFullscreen = useCallback(async () => { const requestFullscreen = useCallback(async () => {
if (!isFullscreenEnabled || !videoElm.current) return; if (!isFullscreenEnabled || !videoElm.current) return;
// per https://wicg.github.io/keyboard-lock/#system-key-press-handler // per https://wicg.github.io/keyboard-lock/#system-key-press-handler
// If keyboard lock is activated after fullscreen is already in effect, then the user my // If keyboard lock is activated after fullscreen is already in effect, then the user my
@ -353,12 +352,12 @@ export default function WebRTCVideo() {
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553 // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
if (e.metaKey && hidKey < 0xE0) { if (e.metaKey && hidKey < 0xE0) {
setTimeout(() => { setTimeout(() => {
handleKeyPress(hidKey, false); sendKeypressEvent(hidKey, false);
}, 10); }, 10);
} }
handleKeyPress(hidKey, true); sendKeypressEvent(hidKey, true);
}, },
[handleKeyPress], [sendKeypressEvent],
); );
const keyUpHandler = useCallback( const keyUpHandler = useCallback(
@ -366,15 +365,15 @@ export default function WebRTCVideo() {
e.preventDefault(); e.preventDefault();
const code = getAdjustedKeyCode(e); const code = getAdjustedKeyCode(e);
const hidKey = keys[code]; const hidKey = keys[code];
if (hidKey === undefined) { if (hidKey === undefined) {
console.warn(`Key up not mapped: ${code}`); console.warn(`Key up not mapped: ${code}`);
return; return;
} }
handleKeyPress(hidKey, false); sendKeypressEvent(hidKey, false);
}, },
[handleKeyPress], [sendKeypressEvent],
); );
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
@ -490,7 +489,7 @@ export default function WebRTCVideo() {
videoElmRefValue.addEventListener("mousemove", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("mousemove", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("pointerdown", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("pointerup", isRelativeMouseMode ? relMouseMoveHandler :absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal, signal,
passive: true, passive: true,
@ -547,8 +546,8 @@ export default function WebRTCVideo() {
return isDefault return isDefault
? {} // No filter if all settings are default (1.0) ? {} // No filter if all settings are default (1.0)
: { : {
filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`, filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`,
}; };
}, [videoSaturation, videoBrightness, videoContrast]); }, [videoSaturation, videoBrightness, videoContrast]);
function getAdjustedKeyCode(e: KeyboardEvent) { function getAdjustedKeyCode(e: KeyboardEvent) {
@ -595,48 +594,48 @@ export default function WebRTCVideo() {
<PointerLockBar show={showPointerLockBar} /> <PointerLockBar show={showPointerLockBar} />
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden"> <div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
<div className="relative flex h-full w-full items-center justify-center"> <div className="relative flex h-full w-full items-center justify-center">
<video <video
ref={videoElm} ref={videoElm}
autoPlay autoPlay
controls={false} controls={false}
onPlaying={onVideoPlaying} onPlaying={onVideoPlaying}
onPlay={onVideoPlaying} onPlay={onVideoPlaying}
muted muted
playsInline playsInline
disablePictureInPicture disablePictureInPicture
controlsList="nofullscreen" controlsList="nofullscreen"
style={videoStyle} style={videoStyle}
className={cx( className={cx(
"max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000", "max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
{ {
"cursor-none": settings.isCursorHidden, "cursor-none": settings.isCursorHidden,
"opacity-0": "opacity-0":
isVideoLoading || isVideoLoading ||
hdmiError || hdmiError ||
peerConnectionState !== "connected", peerConnectionState !== "connected",
"opacity-60!": showPointerLockBar, "opacity-60!": showPointerLockBar,
"animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20": "animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20":
isPlaying, isPlaying,
}, },
)} )}
/> />
{peerConnection?.connectionState == "connected" && ( {peerConnection?.connectionState == "connected" && (
<div <div
style={{ animationDuration: "500ms" }} style={{ animationDuration: "500ms" }}
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center" className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center"
> >
<div className="relative h-full w-full rounded-md"> <div className="relative h-full w-full rounded-md">
<LoadingVideoOverlay show={isVideoLoading} /> <LoadingVideoOverlay show={isVideoLoading} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} /> <HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
<NoAutoplayPermissionsOverlay <NoAutoplayPermissionsOverlay
show={hasNoAutoPlayPermissions} show={hasNoAutoPlayPermissions}
onPlayClick={() => { onPlayClick={() => {
videoElm.current?.play(); videoElm.current?.play();
}} }}
/> />
</div>
</div> </div>
</div> )}
)}
</div> </div>
</div> </div>
<VirtualKeyboard /> <VirtualKeyboard />

View File

@ -436,24 +436,27 @@ export interface KeyboardLedState {
shift: boolean; // Optional, as not all keyboards have a shift LED shift: boolean; // Optional, as not all keyboards have a shift LED
}; };
export const hidKeyBufferSize = 6;
export const hidErrorRollOver = 0x01;
export interface KeysDownState { export interface KeysDownState {
modifier: number; modifier: number;
keys: number[]; keys: number[];
} }
export interface HidState { export interface HidState {
keyboardLedState: KeyboardLedState; altGrArmed: boolean;
setAltGrArmed: (armed: boolean) => void;
altGrTimer: number | null; // _altGrCtrlTime
setAltGrTimer: (timeout: number | null) => void;
altGrCtrlTime: number; // _altGrCtrlTime
setAltGrCtrlTime: (time: number) => void;
keyboardLedState?: KeyboardLedState;
setKeyboardLedState: (state: KeyboardLedState) => void; setKeyboardLedState: (state: KeyboardLedState) => void;
keysDownState: KeysDownState; keysDownState?: KeysDownState;
setKeysDownState: (state: KeysDownState) => void; setKeysDownState: (state: KeysDownState) => void;
keyPressAvailable: boolean;
setKeyPressAvailable: (available: boolean) => void;
isVirtualKeyboardEnabled: boolean; isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void; setVirtualKeyboardEnabled: (enabled: boolean) => void;
@ -465,15 +468,21 @@ export interface HidState {
} }
export const useHidStore = create<HidState>(set => ({ export const useHidStore = create<HidState>(set => ({
keyboardLedState: {} as KeyboardLedState, altGrArmed: false,
setAltGrArmed: (armed: boolean): void => set({ altGrArmed: armed }),
altGrTimer: 0,
setAltGrTimer: (timeout: number | null): void => set({ altGrTimer: timeout }),
altGrCtrlTime: 0,
setAltGrCtrlTime: (time: number): void => set({ altGrCtrlTime: time }),
keyboardLedState: undefined,
setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }), setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }),
keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState, keysDownState: undefined,
setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }), setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }),
keyPressAvailable: true,
setKeyPressAvailable: (available: boolean) => set({ keyPressAvailable: available }),
isVirtualKeyboardEnabled: false, isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }), setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }),

View File

@ -1,29 +1,24 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { KeysDownState, HidState, useHidStore, RTCState, useRTCStore, hidKeyBufferSize, hidErrorRollOver } from "@/hooks/stores"; import { KeysDownState, HidState, useHidStore, RTCState, useRTCStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
export default function useKeyboard() { export default function useKeyboard() {
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel);
const keysDownState = useHidStore((state: HidState) => state.keysDownState);
const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState); const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState);
const keyPressAvailable = useHidStore((state: HidState) => state.keyPressAvailable);
const setKeyPressAvailable = useHidStore((state: HidState) => state.setKeyPressAvailable);
const sendKeyboardEvent = useCallback( const sendKeyboardEvent = useCallback(
(state: KeysDownState) => { (keys: number[], modifiers: number[]) => {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
send("keyboardReport", { keys, modifier: accModifier });
//TODO would be nice if the keyboardReport rpc call returned the current state like keypressReport does //TODO would be nice if the keyboardReport rpc call returned the current state like keypressReport does
send("keyboardReport", { keys: state.keys, modifier: state.modifier });
// We do this for the info bar to display the currently pressed keys for the user // We do this for the info bar to display the currently pressed keys for the user
setKeysDownState(state); setKeysDownState({ keys: keys, modifier: accModifier });
}, },
[rpcDataChannel?.readyState, send, setKeysDownState], [rpcDataChannel?.readyState, send, setKeysDownState],
); );
@ -33,37 +28,30 @@ export default function useKeyboard() {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
send("keypressReport", { key, press }, (resp: JsonRpcResponse) => { send("keypressReport", { key, press }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
// -32601 means the method is not supported console.error("Failed to send keypress:", resp.error);
if (resp.error.code === -32601) { } else {
// if we don't support key press report, we need to disable all that handling const keyDownState = resp.result as KeysDownState;
console.error("Failed calling keypressReport, switching to local handling", resp.error); // We do this for the info bar to display the currently pressed keys for the user
setKeyPressAvailable(false); setKeysDownState(keyDownState);
} else { }
console.error(`Failed to send key ${key} press: ${press}`, resp.error); });
}
} else {
const keyDownState = resp.result as KeysDownState;
// We do this for the info bar to display the currently pressed keys for the user
setKeysDownState(keyDownState);
}
});
}, },
[rpcDataChannel?.readyState, send, setKeyPressAvailable, setKeysDownState], [rpcDataChannel?.readyState, send, setKeysDownState],
); );
const resetKeyboardState = useCallback(() => { const resetKeyboardState = useCallback(() => {
sendKeyboardEvent({ keys: [], modifier: 0 }); sendKeyboardEvent([], []);
}, [sendKeyboardEvent]); }, [sendKeyboardEvent]);
const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => { const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
for (const [index, step] of steps.entries()) { for (const [index, step] of steps.entries()) {
const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || [];
const modifierMask: number = (step.modifiers || []).map(mod => modifiers[mod]).reduce((acc, val) => acc + val, 0); const modifierValues = step.modifiers?.map(mod => modifiers[mod]).filter(Boolean) || [];
// If the step has keys and/or modifiers, press them and hold for the delay // If the step has keys and/or modifiers, press them and hold for the delay
if (keyValues.length > 0 || modifierMask > 0) { if (keyValues.length > 0 || modifierValues.length > 0) {
sendKeyboardEvent({ keys: keyValues, modifier: modifierMask }); sendKeyboardEvent(keyValues, modifierValues);
await new Promise(resolve => setTimeout(resolve, step.delay || 50)); await new Promise(resolve => setTimeout(resolve, step.delay || 50));
resetKeyboardState(); resetKeyboardState();
@ -79,75 +67,5 @@ export default function useKeyboard() {
} }
}; };
// this code exists because we have devices that don't support the keysPress api yet (not current) return { sendKeyboardEvent, sendKeypressEvent, resetKeyboardState, executeMacro };
// so we mirror the device-side code here to keep track of the keyboard state
function handleKeyLocally(state: KeysDownState, key: number, press: boolean): KeysDownState {
const keys = state.keys;
let modifiers = state.modifier;
const modifierMask = hidKeyToModifierMask[key] || 0;
if (modifierMask !== 0) {
if (press) {
modifiers |= modifierMask;
} else {
modifiers &= ~modifierMask;
}
} else {
// handle other keys that are not modifier keys by placing or removing them
// from the key buffer since the buffer tracks currently pressed keys
let overrun = true;
for (let i = 0; i < hidKeyBufferSize && overrun; i++) {
// If we find the key in the buffer the buffer, we either remove it (if press is false)
// or do nothing (if down is true) because the buffer tracks currently pressed keys
// and if we find a zero byte, we can place the key there (if press is true)
if (keys[i] == key || keys[i] == 0) {
if (press) {
keys[i] = key // overwrites the zero byte or the same key if already pressed
} else {
// we are releasing the key, remove it from the buffer
if (keys[i] != 0) {
keys.splice(i, 1);
keys.push(0); // add a zero at the end
}
}
overrun = false // We found a slot for the key
}
// If we reach here it means we didn't find an empty slot or the key in the buffer
if (overrun) {
if (press) {
console.warn(`keyboard buffer overflow, key: ${key} not added`);
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
keys.length = 6;
keys.fill(hidErrorRollOver);
} else {
// If we are releasing a key, and we didn't find it in a slot, who cares?
console.debug(`key ${key} not found in buffer, nothing to release`)
}
}
}
}
return { modifier: modifiers, keys };
}
const handleKeyPress = useCallback(
(key: number, press: boolean) => {
if (keyPressAvailable) {
// if the keyPress api is available, we can just send the key press event
sendKeypressEvent(key, press);
// TODO handle the case where the keyPress api is not available and we need to handle the key locally now...
} else {
// if the keyPress api is not available, we need to handle the key locally
const newKeysDownState = handleKeyLocally(keysDownState, key, press);
setKeysDownState(newKeysDownState);
// then we send the full state
sendKeyboardEvent(newKeysDownState);
}
},
[keyPressAvailable, keysDownState, sendKeyboardEvent, sendKeypressEvent, setKeysDownState],
);
return { handleKeyPress, sendKeyboardEvent, sendKeypressEvent, resetKeyboardState, executeMacro };
} }

View File

@ -142,17 +142,6 @@ export const modifiers = {
MetaRight: 0x80, MetaRight: 0x80,
} as Record<string, number>; } as Record<string, number>;
export const hidKeyToModifierMask = {
0xe0: modifiers.ControlLeft,
0xe1: modifiers.ShiftLeft,
0xe2: modifiers.AltLeft,
0xe3: modifiers.MetaLeft,
0xe4: modifiers.ControlRight,
0xe5: modifiers.ShiftRight,
0xe6: modifiers.AltRight,
0xe7: modifiers.MetaRight,
} as Record<number, number>;
export const modifierDisplayMap: Record<string, string> = { export const modifierDisplayMap: Record<string, string> = {
ControlLeft: "Left Ctrl", ControlLeft: "Left Ctrl",
ControlRight: "Right Ctrl", ControlRight: "Right Ctrl",

View File

@ -29,7 +29,7 @@ import { cx } from "../cva.config";
export default function SettingsRoute() { export default function SettingsRoute() {
const location = useLocation(); const location = useLocation();
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { resetKeyboardState } = useKeyboard(); const { sendKeyboardEvent } = useKeyboard();
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false); const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false); const [showRightGradient, setShowRightGradient] = useState(false);
@ -67,8 +67,8 @@ export default function SettingsRoute() {
useEffect(() => { useEffect(() => {
// disable focus trap // disable focus trap
setTimeout(() => { setTimeout(() => {
// Reset keyboard state. In case the user is pressing a key while enabling the sidebar // Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
resetKeyboardState(); sendKeyboardEvent([], []);
setDisableVideoFocusTrap(true); setDisableVideoFocusTrap(true);
// For some reason, the focus trap is not disabled immediately // For some reason, the focus trap is not disabled immediately
// so we need to blur the active element // so we need to blur the active element
@ -79,7 +79,7 @@ export default function SettingsRoute() {
return () => { return () => {
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
}; };
}, [resetKeyboardState, setDisableVideoFocusTrap]); }, [sendKeyboardEvent, setDisableVideoFocusTrap]);
return ( return (
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white"> <div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">

View File

@ -519,7 +519,7 @@ export default function KvmIdRoute() {
// Cleanup effect // Cleanup effect
const clearInboundRtpStats = useRTCStore((state: RTCState) => state.clearInboundRtpStats); const clearInboundRtpStats = useRTCStore((state: RTCState) => state.clearInboundRtpStats);
const clearCandidatePairStats = useRTCStore((state: RTCState) => state.clearCandidatePairStats); const clearCandidatePairStats = useRTCStore((state: RTCState) => state.clearCandidatePairStats);
const setSidebarView = useUiStore((state: UIState) => state.setSidebarView); const setSidebarView = useUiStore((state: UIState) => state.setSidebarView);
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -597,7 +597,6 @@ export default function KvmIdRoute() {
const keysDownState = useHidStore((state: HidState) => state.keysDownState); const keysDownState = useHidStore((state: HidState) => state.keysDownState);
const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState); const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState);
const setKeyPressAvailable = useHidStore((state: HidState) => state.setKeyPressAvailable);
const [hasUpdated, setHasUpdated] = useState(false); const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
@ -625,7 +624,7 @@ export default function KvmIdRoute() {
console.log("Setting keyboard led state", ledState); console.log("Setting keyboard led state", ledState);
setKeyboardLedState(ledState); setKeyboardLedState(ledState);
} }
if (resp.method === "keysDownState") { if (resp.method === "keysDownState") {
const downState = resp.params as KeysDownState; const downState = resp.params as KeysDownState;
console.log("Setting key down state", downState); console.log("Setting key down state", downState);
@ -668,12 +667,10 @@ export default function KvmIdRoute() {
}); });
}, [rpcDataChannel?.readyState, send, setHdmiState]); }, [rpcDataChannel?.readyState, send, setHdmiState]);
const [needLedState, setNeedLedState] = useState(true);
// request keyboard led state from the device // request keyboard led state from the device
useEffect(() => { useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
if (!needLedState) return; if (keyboardLedState !== undefined) return;
console.log("Requesting keyboard led state"); console.log("Requesting keyboard led state");
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => { send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
@ -683,35 +680,24 @@ export default function KvmIdRoute() {
} }
console.log("Keyboard led state", resp.result); console.log("Keyboard led state", resp.result);
setKeyboardLedState(resp.result as KeyboardLedState); setKeyboardLedState(resp.result as KeyboardLedState);
setNeedLedState(false);
}); });
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]); }, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState]);
const [needKeyDownState, setNeedKeyDownState] = useState(true);
// request keyboard key down state from the device // request keyboard key down state from the device
useEffect(() => { useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
if (!needKeyDownState) return; if (keysDownState !== undefined) return;
console.log("Requesting keys down state"); console.log("Requesting keys down state");
send("getKeyDownState", {}, (resp: JsonRpcResponse) => { send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
// -32601 means the method is not supported console.error("Failed to get key down state", resp.error);
if (resp.error.code === -32601) {
// if we don't support key down state, we know key press is also not available
console.error("Failed to get key down state, switching to old-school", resp.error);
setKeyPressAvailable(false);
} else {
console.error("Failed to get key down state", resp.error);
}
return; return;
} }
console.log("Keyboard key down state", resp.result); console.log("Keyboard key down state", resp.result);
setKeysDownState(resp.result as KeysDownState); setKeysDownState(resp.result as KeysDownState);
setNeedKeyDownState(false);
}); });
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeyPressAvailable, setKeysDownState]); }, [keysDownState, rpcDataChannel?.readyState, send, setKeysDownState]);
// When the update is successful, we need to refresh the client javascript and show a success modal // When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => { useEffect(() => {
@ -772,7 +758,7 @@ export default function KvmIdRoute() {
send("getUpdateStatus", {}, async (resp: JsonRpcResponse) => { send("getUpdateStatus", {}, async (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Failed to get device version: ${resp.error}`); notifications.error(`Failed to get device version: ${resp.error}`);
return return
} }
const result = resp.result as SystemVersionInfo; const result = resp.result as SystemVersionInfo;
@ -909,7 +895,7 @@ interface SidebarContainerProps {
} }
function SidebarContainer(props: SidebarContainerProps) { function SidebarContainer(props: SidebarContainerProps) {
const { sidebarView } = props; const { sidebarView }= props;
return ( return (
<div <div
className={cx( className={cx(