mirror of https://github.com/jetkvm/kvm.git
				
				
				
			Merge branch 'dev' of github.com:jackislanding/jack-kvm into feature/jiggler-scheduler
This commit is contained in:
		
						commit
						5515d33b09
					
				|  | @ -27,6 +27,9 @@ jobs: | ||||||
|         uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 |         uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 | ||||||
|         with: |         with: | ||||||
|           go-version: 1.23.x |           go-version: 1.23.x | ||||||
|  |       - name: Create empty resource directory | ||||||
|  |         run: | | ||||||
|  |           mkdir -p static && touch static/.gitkeep | ||||||
|       - name: Lint |       - name: Lint | ||||||
|         uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1 |         uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1 | ||||||
|         with: |         with: | ||||||
|  |  | ||||||
|  | @ -9,7 +9,6 @@ import { Button } from "./Button"; | ||||||
| import { SelectMenuBasic } from "./SelectMenuBasic"; | import { SelectMenuBasic } from "./SelectMenuBasic"; | ||||||
| import { SettingsSectionHeader } from "./SettingsSectionHeader"; | import { SettingsSectionHeader } from "./SettingsSectionHeader"; | ||||||
| import Fieldset from "./Fieldset"; | import Fieldset from "./Fieldset"; | ||||||
| 
 |  | ||||||
| export interface USBConfig { | export interface USBConfig { | ||||||
|   vendor_id: string; |   vendor_id: string; | ||||||
|   product_id: string; |   product_id: string; | ||||||
|  | @ -119,13 +118,12 @@ export function UsbDeviceSetting() { | ||||||
| 
 | 
 | ||||||
|   const onUsbConfigItemChange = useCallback( |   const onUsbConfigItemChange = useCallback( | ||||||
|     (key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => { |     (key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|       setUsbDeviceConfig(val => { |       setUsbDeviceConfig(prev => ({ | ||||||
|         val[key] = e.target.checked; |         ...prev, | ||||||
|         handleUsbConfigChange(val); |         [key]: e.target.checked, | ||||||
|         return val; |       })); | ||||||
|       }); |  | ||||||
|     }, |     }, | ||||||
|     [handleUsbConfigChange], |     [], | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   const handlePresetChange = useCallback( |   const handlePresetChange = useCallback( | ||||||
|  |  | ||||||
|  | @ -1,10 +1,11 @@ | ||||||
| import React from "react"; | import React from "react"; | ||||||
| import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; | import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; | ||||||
| import { ArrowRightIcon } from "@heroicons/react/16/solid"; | import { ArrowRightIcon } from "@heroicons/react/16/solid"; | ||||||
| import { LinkButton } from "@components/Button"; | import { Button, LinkButton } from "@components/Button"; | ||||||
| import LoadingSpinner from "@components/LoadingSpinner"; | import LoadingSpinner from "@components/LoadingSpinner"; | ||||||
| import { GridCard } from "@components/Card"; | import { GridCard } from "@components/Card"; | ||||||
| import { motion, AnimatePresence } from "motion/react"; | import { motion, AnimatePresence } from "motion/react"; | ||||||
|  | import { LuPlay } from "react-icons/lu"; | ||||||
| 
 | 
 | ||||||
| interface OverlayContentProps { | interface OverlayContentProps { | ||||||
|   children: React.ReactNode; |   children: React.ReactNode; | ||||||
|  | @ -34,7 +35,7 @@ export function LoadingOverlay({ show }: LoadingOverlayProps) { | ||||||
|           exit={{ opacity: 0 }} |           exit={{ opacity: 0 }} | ||||||
|           transition={{ |           transition={{ | ||||||
|             duration: show ? 0.3 : 0.1, |             duration: show ? 0.3 : 0.1, | ||||||
|             ease: "easeInOut" |             ease: "easeInOut", | ||||||
|           }} |           }} | ||||||
|         > |         > | ||||||
|           <OverlayContent> |           <OverlayContent> | ||||||
|  | @ -68,7 +69,7 @@ export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) { | ||||||
|           exit={{ opacity: 0 }} |           exit={{ opacity: 0 }} | ||||||
|           transition={{ |           transition={{ | ||||||
|             duration: 0.3, |             duration: 0.3, | ||||||
|             ease: "easeInOut" |             ease: "easeInOut", | ||||||
|           }} |           }} | ||||||
|         > |         > | ||||||
|           <OverlayContent> |           <OverlayContent> | ||||||
|  | @ -118,25 +119,27 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) { | ||||||
|       <AnimatePresence> |       <AnimatePresence> | ||||||
|         {show && isNoSignal && ( |         {show && isNoSignal && ( | ||||||
|           <motion.div |           <motion.div | ||||||
|             className="absolute inset-0 w-full h-full aspect-video" |             className="absolute inset-0 aspect-video h-full w-full" | ||||||
|             initial={{ opacity: 0 }} |             initial={{ opacity: 0 }} | ||||||
|             animate={{ opacity: 1 }} |             animate={{ opacity: 1 }} | ||||||
|             exit={{ opacity: 0 }} |             exit={{ opacity: 0 }} | ||||||
|             transition={{ |             transition={{ | ||||||
|               duration: 0.3, |               duration: 0.3, | ||||||
|               ease: "easeInOut" |               ease: "easeInOut", | ||||||
|             }} |             }} | ||||||
|           > |           > | ||||||
|             <OverlayContent> |             <OverlayContent> | ||||||
|               <div className="flex flex-col items-start gap-y-1"> |               <div className="flex flex-col items-start gap-y-1"> | ||||||
|                 <ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" /> |                 <ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" /> | ||||||
|                 <div className="text-sm text-left text-slate-700 dark:text-slate-300"> |                 <div className="text-left text-sm text-slate-700 dark:text-slate-300"> | ||||||
|                   <div className="space-y-4"> |                   <div className="space-y-4"> | ||||||
|                     <div className="space-y-2 text-black dark:text-white"> |                     <div className="space-y-2 text-black dark:text-white"> | ||||||
|                       <h2 className="text-xl font-bold">No HDMI signal detected.</h2> |                       <h2 className="text-xl font-bold">No HDMI signal detected.</h2> | ||||||
|                       <ul className="list-disc space-y-2 pl-4 text-left"> |                       <ul className="list-disc space-y-2 pl-4 text-left"> | ||||||
|                         <li>Ensure the HDMI cable securely connected at both ends</li> |                         <li>Ensure the HDMI cable securely connected at both ends</li> | ||||||
|                         <li>Ensure source device is powered on and outputting a signal</li> |                         <li> | ||||||
|  |                           Ensure source device is powered on and outputting a signal | ||||||
|  |                         </li> | ||||||
|                         <li> |                         <li> | ||||||
|                           If using an adapter, it's compatible and functioning |                           If using an adapter, it's compatible and functioning | ||||||
|                           correctly |                           correctly | ||||||
|  | @ -169,7 +172,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) { | ||||||
|             exit={{ opacity: 0 }} |             exit={{ opacity: 0 }} | ||||||
|             transition={{ |             transition={{ | ||||||
|               duration: 0.3, |               duration: 0.3, | ||||||
|               ease: "easeInOut" |               ease: "easeInOut", | ||||||
|             }} |             }} | ||||||
|           > |           > | ||||||
|             <OverlayContent> |             <OverlayContent> | ||||||
|  | @ -187,7 +190,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) { | ||||||
|                     </div> |                     </div> | ||||||
|                     <div> |                     <div> | ||||||
|                       <LinkButton |                       <LinkButton | ||||||
|                         to={"/help/hdmi-error"} |                         to={"https://jetkvm.com/docs/getting-started/troubleshooting"} | ||||||
|                         theme="light" |                         theme="light" | ||||||
|                         text="Learn more" |                         text="Learn more" | ||||||
|                         TrailingIcon={ArrowRightIcon} |                         TrailingIcon={ArrowRightIcon} | ||||||
|  | @ -204,3 +207,54 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) { | ||||||
|     </> |     </> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | interface NoAutoplayPermissionsOverlayProps { | ||||||
|  |   show: boolean; | ||||||
|  |   onPlayClick: () => void; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function NoAutoplayPermissionsOverlay({ | ||||||
|  |   show, | ||||||
|  |   onPlayClick, | ||||||
|  | }: NoAutoplayPermissionsOverlayProps) { | ||||||
|  |   return ( | ||||||
|  |     <AnimatePresence> | ||||||
|  |       {show && ( | ||||||
|  |         <motion.div | ||||||
|  |           className="absolute inset-0 z-10 aspect-video h-full w-full" | ||||||
|  |           initial={{ opacity: 0 }} | ||||||
|  |           animate={{ opacity: 1 }} | ||||||
|  |           exit={{ opacity: 0 }} | ||||||
|  |           transition={{ | ||||||
|  |             duration: 0.3, | ||||||
|  |             ease: "easeInOut", | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           <OverlayContent> | ||||||
|  |             <div className="space-y-4"> | ||||||
|  |               <h2 className="text-2xl font-extrabold text-black dark:text-white"> | ||||||
|  |                 Autoplay permissions required | ||||||
|  |               </h2> | ||||||
|  | 
 | ||||||
|  |               <div className="space-y-2 text-center"> | ||||||
|  |                 <div> | ||||||
|  |                   <Button | ||||||
|  |                     size="MD" | ||||||
|  |                     theme="primary" | ||||||
|  |                     LeadingIcon={LuPlay} | ||||||
|  |                     text="Manually start stream" | ||||||
|  |                     onClick={onPlayClick} | ||||||
|  |                   /> | ||||||
|  |                 </div> | ||||||
|  | 
 | ||||||
|  |                 <div className="text-xs text-slate-600 dark:text-slate-400"> | ||||||
|  |                   Please adjust browser settings to enable autoplay | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </OverlayContent> | ||||||
|  |         </motion.div> | ||||||
|  |       )} | ||||||
|  |     </AnimatePresence> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import { useCallback, useEffect, useRef, useState } from "react"; | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||||||
| import { | import { | ||||||
|   useDeviceSettingsStore, |   useDeviceSettingsStore, | ||||||
|   useHidStore, |   useHidStore, | ||||||
|  | @ -15,7 +15,7 @@ import Actionbar from "@components/ActionBar"; | ||||||
| import InfoBar from "@components/InfoBar"; | import InfoBar from "@components/InfoBar"; | ||||||
| import useKeyboard from "@/hooks/useKeyboard"; | import useKeyboard from "@/hooks/useKeyboard"; | ||||||
| import { useJsonRpc } from "@/hooks/useJsonRpc"; | import { useJsonRpc } from "@/hooks/useJsonRpc"; | ||||||
| import { HDMIErrorOverlay } from "./VideoOverlay"; | import { HDMIErrorOverlay, NoAutoplayPermissionsOverlay } from "./VideoOverlay"; | ||||||
| import { ConnectionErrorOverlay } from "./VideoOverlay"; | import { ConnectionErrorOverlay } from "./VideoOverlay"; | ||||||
| import { LoadingOverlay } from "./VideoOverlay"; | import { LoadingOverlay } from "./VideoOverlay"; | ||||||
| 
 | 
 | ||||||
|  | @ -47,7 +47,7 @@ export default function WebRTCVideo() { | ||||||
|   const hdmiState = useVideoStore(state => state.hdmiState); |   const hdmiState = useVideoStore(state => 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 isLoading = !hdmiError && !isPlaying; |   const isLoading = !hdmiError && !isPlaying; | ||||||
|   const isConnectionError = ["error", "failed", "disconnected"].includes( |   const isConnectionError = ["error", "failed", "disconnected", "closed"].includes( | ||||||
|     peerConnectionState || "", |     peerConnectionState || "", | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|  | @ -94,7 +94,7 @@ export default function WebRTCVideo() { | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   // Mouse-related
 |   // Mouse-related
 | ||||||
|   const calcDelta = (pos: number) => Math.abs(pos) < 10 ? pos * 2 : pos; |   const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos); | ||||||
|   const sendRelMouseMovement = useCallback( |   const sendRelMouseMovement = useCallback( | ||||||
|     (x: number, y: number, buttons: number) => { |     (x: number, y: number, buttons: number) => { | ||||||
|       if (settings.mouseMode !== "relative") return; |       if (settings.mouseMode !== "relative") return; | ||||||
|  | @ -168,7 +168,14 @@ export default function WebRTCVideo() { | ||||||
|       const { buttons } = e; |       const { buttons } = e; | ||||||
|       sendAbsMouseMovement(x, y, buttons); |       sendAbsMouseMovement(x, y, buttons); | ||||||
|     }, |     }, | ||||||
|     [sendAbsMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight, settings.mouseMode], |     [ | ||||||
|  |       sendAbsMouseMovement, | ||||||
|  |       videoClientHeight, | ||||||
|  |       videoClientWidth, | ||||||
|  |       videoWidth, | ||||||
|  |       videoHeight, | ||||||
|  |       settings.mouseMode, | ||||||
|  |     ], | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity); |   const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity); | ||||||
|  | @ -355,28 +362,6 @@ export default function WebRTCVideo() { | ||||||
|     ], |     ], | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   // Effect hooks
 |  | ||||||
|   useEffect( |  | ||||||
|     function setupKeyboardEvents() { |  | ||||||
|       const abortController = new AbortController(); |  | ||||||
|       const signal = abortController.signal; |  | ||||||
| 
 |  | ||||||
|       document.addEventListener("keydown", keyDownHandler, { signal }); |  | ||||||
|       document.addEventListener("keyup", keyUpHandler, { signal }); |  | ||||||
| 
 |  | ||||||
|       // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 |  | ||||||
|       // @ts-expect-error
 |  | ||||||
|       window.clearKeys = () => sendKeyboardEvent([], []); |  | ||||||
|       window.addEventListener("blur", resetKeyboardState, { signal }); |  | ||||||
|       document.addEventListener("visibilitychange", resetKeyboardState, { signal }); |  | ||||||
| 
 |  | ||||||
|       return () => { |  | ||||||
|         abortController.abort(); |  | ||||||
|       }; |  | ||||||
|     }, |  | ||||||
|     [keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent], |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { |   const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { | ||||||
|     // In fullscreen mode in chrome & safari, the space key is used to pause/play the video
 |     // In fullscreen mode in chrome & safari, the space key is used to pause/play the video
 | ||||||
|     // there is no way to prevent this, so we need to simply force play the video when it's paused.
 |     // there is no way to prevent this, so we need to simply force play the video when it's paused.
 | ||||||
|  | @ -389,71 +374,6 @@ export default function WebRTCVideo() { | ||||||
|     } |     } | ||||||
|   }, []); |   }, []); | ||||||
| 
 | 
 | ||||||
|   useEffect( |  | ||||||
|     function setupVideoEventListeners() { |  | ||||||
|       let videoElmRefValue = null; |  | ||||||
|       if (!videoElm.current) return; |  | ||||||
|       videoElmRefValue = videoElm.current; |  | ||||||
|       const abortController = new AbortController(); |  | ||||||
|       const signal = abortController.signal; |  | ||||||
| 
 |  | ||||||
|       videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal }); |  | ||||||
|       videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal }); |  | ||||||
|       videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal }); |  | ||||||
|       videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal }); |  | ||||||
|       videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { |  | ||||||
|         signal, |  | ||||||
|         passive: true, |  | ||||||
|       }); |  | ||||||
|       videoElmRefValue.addEventListener( |  | ||||||
|         "contextmenu", |  | ||||||
|         (e: MouseEvent) => e.preventDefault(), |  | ||||||
|         { signal }, |  | ||||||
|       ); |  | ||||||
|       videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal }); |  | ||||||
| 
 |  | ||||||
|       const local = resetMousePosition; |  | ||||||
|       window.addEventListener("blur", local, { signal }); |  | ||||||
|       document.addEventListener("visibilitychange", local, { signal }); |  | ||||||
| 
 |  | ||||||
|       return () => { |  | ||||||
|         if (videoElmRefValue) abortController.abort(); |  | ||||||
|       }; |  | ||||||
|     }, |  | ||||||
|     [ |  | ||||||
|       absMouseMoveHandler, |  | ||||||
|       resetMousePosition, |  | ||||||
|       onVideoPlaying, |  | ||||||
|       mouseWheelHandler, |  | ||||||
|       videoKeyUpHandler, |  | ||||||
|     ], |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   useEffect( |  | ||||||
|     function setupRelativeMouseEventListeners() { |  | ||||||
|       if (settings.mouseMode !== "relative") return; |  | ||||||
| 
 |  | ||||||
|       const abortController = new AbortController(); |  | ||||||
|       const signal = abortController.signal; |  | ||||||
| 
 |  | ||||||
|       // bind to body to capture all mouse events
 |  | ||||||
|       const body = document.querySelector("body"); |  | ||||||
|       if (!body) return; |  | ||||||
| 
 |  | ||||||
|       body.addEventListener("mousemove", relMouseMoveHandler, { signal }); |  | ||||||
|       body.addEventListener("pointerdown", relMouseMoveHandler, { signal }); |  | ||||||
|       body.addEventListener("pointerup", relMouseMoveHandler, { signal }); |  | ||||||
| 
 |  | ||||||
|       return () => { |  | ||||||
|         abortController.abort(); |  | ||||||
| 
 |  | ||||||
|         body.removeEventListener("mousemove", relMouseMoveHandler); |  | ||||||
|         body.removeEventListener("pointerdown", relMouseMoveHandler); |  | ||||||
|         body.removeEventListener("pointerup", relMouseMoveHandler); |  | ||||||
|       }; |  | ||||||
|     }, [settings.mouseMode, relMouseMoveHandler], |  | ||||||
|   ) |  | ||||||
| 
 |  | ||||||
|   useEffect( |   useEffect( | ||||||
|     function updateVideoStream() { |     function updateVideoStream() { | ||||||
|       if (!mediaStream) return; |       if (!mediaStream) return; | ||||||
|  | @ -476,6 +396,129 @@ export default function WebRTCVideo() { | ||||||
|     ], |     ], | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|  |   // Setup Keyboard Events
 | ||||||
|  |   useEffect( | ||||||
|  |     function setupKeyboardEvents() { | ||||||
|  |       const abortController = new AbortController(); | ||||||
|  |       const signal = abortController.signal; | ||||||
|  | 
 | ||||||
|  |       document.addEventListener("keydown", keyDownHandler, { signal }); | ||||||
|  |       document.addEventListener("keyup", keyUpHandler, { signal }); | ||||||
|  | 
 | ||||||
|  |       // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 | ||||||
|  |       // @ts-expect-error
 | ||||||
|  |       window.clearKeys = () => sendKeyboardEvent([], []); | ||||||
|  |       window.addEventListener("blur", resetKeyboardState, { signal }); | ||||||
|  |       document.addEventListener("visibilitychange", resetKeyboardState, { signal }); | ||||||
|  | 
 | ||||||
|  |       return () => { | ||||||
|  |         abortController.abort(); | ||||||
|  |       }; | ||||||
|  |     }, | ||||||
|  |     [keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent], | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   // Setup Video Event Listeners
 | ||||||
|  |   useEffect( | ||||||
|  |     function setupVideoEventListeners() { | ||||||
|  |       const videoElmRefValue = videoElm.current; | ||||||
|  |       if (!videoElmRefValue) return; | ||||||
|  | 
 | ||||||
|  |       const abortController = new AbortController(); | ||||||
|  |       const signal = abortController.signal; | ||||||
|  | 
 | ||||||
|  |       // To prevent the video from being paused when the user presses a space in fullscreen mode
 | ||||||
|  |       videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal }); | ||||||
|  | 
 | ||||||
|  |       // We need to know when the video is playing to update state and video size
 | ||||||
|  |       videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal }); | ||||||
|  | 
 | ||||||
|  |       return () => { | ||||||
|  |         abortController.abort(); | ||||||
|  |       }; | ||||||
|  |     }, | ||||||
|  |     [ | ||||||
|  |       absMouseMoveHandler, | ||||||
|  |       resetMousePosition, | ||||||
|  |       onVideoPlaying, | ||||||
|  |       mouseWheelHandler, | ||||||
|  |       videoKeyUpHandler, | ||||||
|  |     ], | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   // Setup Absolute Mouse Events
 | ||||||
|  |   useEffect( | ||||||
|  |     function setAbsoluteMouseModeEventListeners() { | ||||||
|  |       const videoElmRefValue = videoElm.current; | ||||||
|  |       if (!videoElmRefValue) return; | ||||||
|  | 
 | ||||||
|  |       if (settings.mouseMode !== "absolute") return; | ||||||
|  | 
 | ||||||
|  |       const abortController = new AbortController(); | ||||||
|  |       const signal = abortController.signal; | ||||||
|  | 
 | ||||||
|  |       videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal }); | ||||||
|  |       videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal }); | ||||||
|  |       videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal }); | ||||||
|  |       videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { | ||||||
|  |         signal, | ||||||
|  |         passive: true, | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       // Reset the mouse position when the window is blurred or the document is hidden
 | ||||||
|  |       const local = resetMousePosition; | ||||||
|  |       window.addEventListener("blur", local, { signal }); | ||||||
|  |       document.addEventListener("visibilitychange", local, { signal }); | ||||||
|  | 
 | ||||||
|  |       return () => { | ||||||
|  |         abortController.abort(); | ||||||
|  |       }; | ||||||
|  |     }, | ||||||
|  |     [absMouseMoveHandler, mouseWheelHandler, resetMousePosition, settings.mouseMode], | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   // Setup Relative Mouse Events
 | ||||||
|  |   const containerRef = useRef<HTMLDivElement>(null); | ||||||
|  |   useEffect( | ||||||
|  |     function setupRelativeMouseEventListeners() { | ||||||
|  |       if (settings.mouseMode !== "relative") return; | ||||||
|  | 
 | ||||||
|  |       const abortController = new AbortController(); | ||||||
|  |       const signal = abortController.signal; | ||||||
|  | 
 | ||||||
|  |       // We bind to the larger container in relative mode because of delta between the acceleration of the local
 | ||||||
|  |       // mouse and the mouse movement of the remote mouse. This simply makes it a bit less painful to use.
 | ||||||
|  |       // When we get Pointer Lock support, we can remove this.
 | ||||||
|  |       const containerElm = containerRef.current; | ||||||
|  |       if (!containerElm) return; | ||||||
|  | 
 | ||||||
|  |       containerElm.addEventListener("mousemove", relMouseMoveHandler, { signal }); | ||||||
|  |       containerElm.addEventListener("pointerdown", relMouseMoveHandler, { signal }); | ||||||
|  |       containerElm.addEventListener("pointerup", relMouseMoveHandler, { signal }); | ||||||
|  | 
 | ||||||
|  |       containerElm.addEventListener("wheel", mouseWheelHandler, { | ||||||
|  |         signal, | ||||||
|  |         passive: true, | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       const preventContextMenu = (e: MouseEvent) => e.preventDefault(); | ||||||
|  |       containerElm.addEventListener("contextmenu", preventContextMenu, { signal }); | ||||||
|  | 
 | ||||||
|  |       return () => { | ||||||
|  |         abortController.abort(); | ||||||
|  |       }; | ||||||
|  |     }, | ||||||
|  |     [settings.mouseMode, relMouseMoveHandler, mouseWheelHandler], | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   const hasNoAutoPlayPermissions = useMemo(() => { | ||||||
|  |     if (peerConnectionState !== "connected") return false; | ||||||
|  |     if (isPlaying) return false; | ||||||
|  |     if (hdmiError) return false; | ||||||
|  |     if (videoHeight === 0 || videoWidth === 0) return false; | ||||||
|  |     return true; | ||||||
|  |   }, [peerConnectionState, isPlaying, hdmiError, videoHeight, videoWidth]); | ||||||
|  | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div className="grid h-full w-full grid-rows-layout"> |     <div className="grid h-full w-full grid-rows-layout"> | ||||||
|       <div className="min-h-[39.5px]"> |       <div className="min-h-[39.5px]"> | ||||||
|  | @ -490,7 +533,12 @@ export default function WebRTCVideo() { | ||||||
|         </fieldset> |         </fieldset> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <div className="h-full overflow-hidden"> |       <div | ||||||
|  |         ref={containerRef} | ||||||
|  |         className={cx("h-full overflow-hidden", { | ||||||
|  |           "cursor-none": settings.mouseMode === "relative" && settings.isCursorHidden, | ||||||
|  |         })} | ||||||
|  |       > | ||||||
|         <div className="relative h-full"> |         <div className="relative h-full"> | ||||||
|           <div |           <div | ||||||
|             className={cx( |             className={cx( | ||||||
|  | @ -519,7 +567,9 @@ export default function WebRTCVideo() { | ||||||
|                         className={cx( |                         className={cx( | ||||||
|                           "outline-50 max-h-full max-w-full object-contain transition-all duration-1000", |                           "outline-50 max-h-full max-w-full object-contain transition-all duration-1000", | ||||||
|                           { |                           { | ||||||
|                             "cursor-none": settings.isCursorHidden, |                             "cursor-none": | ||||||
|  |                               settings.mouseMode === "absolute" && | ||||||
|  |                               settings.isCursorHidden, | ||||||
|                             "opacity-0": isLoading || isConnectionError || hdmiError, |                             "opacity-0": isLoading || isConnectionError || hdmiError, | ||||||
|                             "animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20": |                             "animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20": | ||||||
|                               isPlaying, |                               isPlaying, | ||||||
|  | @ -534,6 +584,12 @@ export default function WebRTCVideo() { | ||||||
|                           <LoadingOverlay show={isLoading} /> |                           <LoadingOverlay show={isLoading} /> | ||||||
|                           <ConnectionErrorOverlay show={isConnectionError} /> |                           <ConnectionErrorOverlay show={isConnectionError} /> | ||||||
|                           <HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} /> |                           <HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} /> | ||||||
|  |                           <NoAutoplayPermissionsOverlay | ||||||
|  |                             show={hasNoAutoPlayPermissions} | ||||||
|  |                             onPlayClick={() => { | ||||||
|  |                               videoElm.current?.play(); | ||||||
|  |                             }} | ||||||
|  |                           /> | ||||||
|                         </div> |                         </div> | ||||||
|                       </div> |                       </div> | ||||||
|                     </div> |                     </div> | ||||||
|  |  | ||||||
|  | @ -130,10 +130,68 @@ export default function KvmIdRoute() { | ||||||
|   const setDiskChannel = useRTCStore(state => state.setDiskChannel); |   const setDiskChannel = useRTCStore(state => state.setDiskChannel); | ||||||
|   const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel); |   const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel); | ||||||
|   const setTransceiver = useRTCStore(state => state.setTransceiver); |   const setTransceiver = useRTCStore(state => state.setTransceiver); | ||||||
|  |   const location = useLocation(); | ||||||
|  | 
 | ||||||
|  |   const [connectionAttempts, setConnectionAttempts] = useState(0); | ||||||
|  | 
 | ||||||
|  |   const [startedConnectingAt, setStartedConnectingAt] = useState<Date | null>(null); | ||||||
|  |   const [connectedAt, setConnectedAt] = useState<Date | null>(null); | ||||||
|  | 
 | ||||||
|  |   const [connectionFailed, setConnectionFailed] = useState(false); | ||||||
| 
 | 
 | ||||||
|   const navigate = useNavigate(); |   const navigate = useNavigate(); | ||||||
|   const { otaState, setOtaState, setModalView } = useUpdateStore(); |   const { otaState, setOtaState, setModalView } = useUpdateStore(); | ||||||
| 
 | 
 | ||||||
|  |   const closePeerConnection = useCallback( | ||||||
|  |     function closePeerConnection() { | ||||||
|  |       peerConnection?.close(); | ||||||
|  |       // "closed" is a valid RTCPeerConnection state according to the WebRTC spec
 | ||||||
|  |       // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState#closed
 | ||||||
|  |       // However, the onconnectionstatechange event doesn't fire when close() is called manually
 | ||||||
|  |       // So we need to explicitly update our state to maintain consistency
 | ||||||
|  |       // I don't know why this is happening, but this is the best way I can think of to handle it
 | ||||||
|  |       setPeerConnectionState("closed"); | ||||||
|  |     }, | ||||||
|  |     [peerConnection, setPeerConnectionState], | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     const connectionAttemptsThreshold = 30; | ||||||
|  |     if (connectionAttempts > connectionAttemptsThreshold) { | ||||||
|  |       console.log(`Connection failed after ${connectionAttempts} attempts.`); | ||||||
|  |       setConnectionFailed(true); | ||||||
|  |       closePeerConnection(); | ||||||
|  |     } | ||||||
|  |   }, [connectionAttempts, closePeerConnection]); | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     // Skip if already connected
 | ||||||
|  |     if (connectedAt) return; | ||||||
|  | 
 | ||||||
|  |     // Skip if connection is declared as failed
 | ||||||
|  |     if (connectionFailed) return; | ||||||
|  | 
 | ||||||
|  |     const interval = setInterval(() => { | ||||||
|  |       console.log("Checking connection status"); | ||||||
|  | 
 | ||||||
|  |       // Skip if connection hasn't started
 | ||||||
|  |       if (!startedConnectingAt) return; | ||||||
|  | 
 | ||||||
|  |       const elapsedTime = Math.floor( | ||||||
|  |         new Date().getTime() - startedConnectingAt.getTime(), | ||||||
|  |       ); | ||||||
|  | 
 | ||||||
|  |       // Fail connection if it's been over X seconds since we started connecting
 | ||||||
|  |       if (elapsedTime > 60 * 1000) { | ||||||
|  |         console.error(`Connection failed after ${elapsedTime} ms.`); | ||||||
|  |         setConnectionFailed(true); | ||||||
|  |         closePeerConnection(); | ||||||
|  |       } | ||||||
|  |     }, 1000); | ||||||
|  | 
 | ||||||
|  |     return () => clearInterval(interval); | ||||||
|  |   }, [closePeerConnection, connectedAt, connectionFailed, startedConnectingAt]); | ||||||
|  | 
 | ||||||
|   const sdp = useCallback( |   const sdp = useCallback( | ||||||
|     async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => { |     async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => { | ||||||
|       if (!pc) return; |       if (!pc) return; | ||||||
|  | @ -169,7 +227,7 @@ export default function KvmIdRoute() { | ||||||
|           // - In device mode, the device api would timeout, the fetch would throw an error, therefore the catch block would be hit
 |           // - In device mode, the device api would timeout, the fetch would throw an error, therefore the catch block would be hit
 | ||||||
|           // Regardless, we should close the peer connection and let the useInterval handle reconnecting
 |           // Regardless, we should close the peer connection and let the useInterval handle reconnecting
 | ||||||
|           if (!res.ok) { |           if (!res.ok) { | ||||||
|             pc?.close(); |             closePeerConnection(); | ||||||
|             console.error(`Error setting SDP - Status: ${res.status}}`, json); |             console.error(`Error setting SDP - Status: ${res.status}}`, json); | ||||||
|             return; |             return; | ||||||
|           } |           } | ||||||
|  | @ -180,14 +238,20 @@ export default function KvmIdRoute() { | ||||||
|         ).catch(e => console.log(`Error setting remote description: ${e}`)); |         ).catch(e => console.log(`Error setting remote description: ${e}`)); | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
|         console.error(`Error setting SDP: ${error}`); |         console.error(`Error setting SDP: ${error}`); | ||||||
|         pc?.close(); |         closePeerConnection(); | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     [navigate, params.id], |     [closePeerConnection, navigate, params.id], | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   const connectWebRTC = useCallback(async () => { |   const connectWebRTC = useCallback(async () => { | ||||||
|     console.log("Attempting to connect WebRTC"); |     console.log("Attempting to connect WebRTC"); | ||||||
|  | 
 | ||||||
|  |     // Track connection status to detect failures and show error overlay
 | ||||||
|  |     setConnectionAttempts(x => x + 1); | ||||||
|  |     setStartedConnectingAt(new Date()); | ||||||
|  |     setConnectedAt(null); | ||||||
|  | 
 | ||||||
|     const pc = new RTCPeerConnection({ |     const pc = new RTCPeerConnection({ | ||||||
|       // We only use STUN or TURN servers if we're in the cloud
 |       // We only use STUN or TURN servers if we're in the cloud
 | ||||||
|       ...(isInCloud && iceConfig?.iceServers |       ...(isInCloud && iceConfig?.iceServers | ||||||
|  | @ -197,6 +261,11 @@ export default function KvmIdRoute() { | ||||||
| 
 | 
 | ||||||
|     // Set up event listeners and data channels
 |     // Set up event listeners and data channels
 | ||||||
|     pc.onconnectionstatechange = () => { |     pc.onconnectionstatechange = () => { | ||||||
|  |       // If the connection state is connected, we reset the connection attempts.
 | ||||||
|  |       if (pc.connectionState === "connected") { | ||||||
|  |         setConnectionAttempts(0); | ||||||
|  |         setConnectedAt(new Date()); | ||||||
|  |       } | ||||||
|       setPeerConnectionState(pc.connectionState); |       setPeerConnectionState(pc.connectionState); | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|  | @ -236,16 +305,35 @@ export default function KvmIdRoute() { | ||||||
|     setTransceiver, |     setTransceiver, | ||||||
|   ]); |   ]); | ||||||
| 
 | 
 | ||||||
|   // WebRTC connection management
 |   useEffect(() => { | ||||||
|   useInterval(() => { |     console.log("Attempting to connect WebRTC"); | ||||||
|  | 
 | ||||||
|  |     // If we're in an other session, we don't need to connect
 | ||||||
|  |     if (location.pathname.includes("other-session")) return; | ||||||
|  | 
 | ||||||
|  |     // If we're already connected or connecting, we don't need to connect
 | ||||||
|     if ( |     if ( | ||||||
|       ["connected", "connecting", "new"].includes(peerConnection?.connectionState ?? "") |       ["connected", "connecting", "new"].includes(peerConnection?.connectionState ?? "") | ||||||
|     ) { |     ) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     if (location.pathname.includes("other-session")) return; | 
 | ||||||
|  |     // In certain cases, we want to never connect again. This happens when we've tried for a long time and failed
 | ||||||
|  |     if (connectionFailed) { | ||||||
|  |       console.log("Connection failed. We won't attempt to connect again."); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const interval = setInterval(() => { | ||||||
|       connectWebRTC(); |       connectWebRTC(); | ||||||
|     }, 3000); |     }, 3000); | ||||||
|  |     return () => clearInterval(interval); | ||||||
|  |   }, [ | ||||||
|  |     connectWebRTC, | ||||||
|  |     connectionFailed, | ||||||
|  |     location.pathname, | ||||||
|  |     peerConnection?.connectionState, | ||||||
|  |   ]); | ||||||
| 
 | 
 | ||||||
|   // On boot, if the connection state is undefined, we connect to the WebRTC
 |   // On boot, if the connection state is undefined, we connect to the WebRTC
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|  | @ -431,7 +519,6 @@ export default function KvmIdRoute() { | ||||||
|   }, [kvmTerminal, peerConnection, serialConsole]); |   }, [kvmTerminal, peerConnection, serialConsole]); | ||||||
| 
 | 
 | ||||||
|   const outlet = useOutlet(); |   const outlet = useOutlet(); | ||||||
|   const location = useLocation(); |  | ||||||
|   const onModalClose = useCallback(() => { |   const onModalClose = useCallback(() => { | ||||||
|     if (location.pathname !== "/other-session") navigateTo("/"); |     if (location.pathname !== "/other-session") navigateTo("/"); | ||||||
|   }, [navigateTo, location.pathname]); |   }, [navigateTo, location.pathname]); | ||||||
|  | @ -523,6 +610,7 @@ export default function KvmIdRoute() { | ||||||
|         }} |         }} | ||||||
|       > |       > | ||||||
|         <Modal open={outlet !== null} onClose={onModalClose}> |         <Modal open={outlet !== null} onClose={onModalClose}> | ||||||
|  |           {/* The 'used by other session' modal needs to have access to the connectWebRTC function */} | ||||||
|           <Outlet context={{ connectWebRTC }} /> |           <Outlet context={{ connectWebRTC }} /> | ||||||
|         </Modal> |         </Modal> | ||||||
|       </div> |       </div> | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue