diff --git a/server/proto/vm.go b/server/proto/vm.go index 11d417f..21d3844 100644 --- a/server/proto/vm.go +++ b/server/proto/vm.go @@ -87,6 +87,10 @@ type GetOLEDRsp struct { Sleep int `json:"sleep"` } +type GetGetHdmiStateRsp struct { + Enabled bool `json:"enabled"` +} + type GetSSHStateRsp struct { Enabled bool `json:"enabled"` } diff --git a/server/router/vm.go b/server/router/vm.go index 67a808a..f5a78a4 100644 --- a/server/router/vm.go +++ b/server/router/vm.go @@ -35,8 +35,9 @@ func vmRouter(r *gin.Engine) { api.GET("/vm/oled", service.GetOLED) // get OLED configuration api.POST("/vm/oled", service.SetOLED) // set OLED configuration + // Only supported by PCIe version api.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state - api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi (pcie only) + api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi api.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi api.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi diff --git a/server/service/stream/direct/h264.go b/server/service/stream/direct/h264.go index df43266..0803836 100644 --- a/server/service/stream/direct/h264.go +++ b/server/service/stream/direct/h264.go @@ -21,9 +21,10 @@ type Frame struct { } var ( - mutex = sync.Mutex{} - wsMap = make(map[*websocket.Conn]bool) - upgrader = websocket.Upgrader{ + mutex = sync.Mutex{} + wsMap = make(map[*websocket.Conn]bool) + isSending = false + upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, @@ -47,7 +48,7 @@ func Connect(c *gin.Context) { mutex.Lock() wsMap[ws] = true - if len(wsMap) == 1 { + if len(wsMap) == 1 && !isSending { go send() } mutex.Unlock() @@ -62,6 +63,7 @@ func Connect(c *gin.Context) { } func send() { + isSending = true screen := common.GetScreen() common.CheckScreen() @@ -76,6 +78,7 @@ func send() { for range ticker.C { if len(wsMap) == 0 { + isSending = false return } diff --git a/server/service/vm/hdmi.go b/server/service/vm/hdmi.go index 0e672cc..f6556cb 100644 --- a/server/service/vm/hdmi.go +++ b/server/service/vm/hdmi.go @@ -1,15 +1,15 @@ package vm import ( + "time" + "NanoKVM-Server/common" "NanoKVM-Server/proto" - "time" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" ) -// ideally read state from the device var hdmiEnabled = true func (s *Service) ResetHdmi(c *gin.Context) { @@ -53,15 +53,9 @@ func (s *Service) DisableHdmi(c *gin.Context) { func (s *Service) GetHdmiState(c *gin.Context) { var rsp proto.Response - if hdmiEnabled { - rsp.OkRspWithData(c, &map[string]string{ - "state": "enabled", - }) - } else { - rsp.OkRspWithData(c, &map[string]string{ - "state": "disabled", - }) - } + rsp.OkRspWithData(c, &proto.GetGetHdmiStateRsp{ + Enabled: hdmiEnabled, + }) log.Debug("get hdmi state") } diff --git a/web/package.json b/web/package.json index bea67e7..bed5178 100644 --- a/web/package.json +++ b/web/package.json @@ -34,7 +34,8 @@ "react-simple-keyboard": "^3.8.19", "semver": "^7.6.3", "vaul": "^0.9.9", - "websocket": "^1.0.35" + "websocket": "^1.0.35", + "yocto-queue": "^1.2.1" }, "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.3.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index d88133c..36e5dbb 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: websocket: specifier: ^1.0.35 version: 1.0.35 + yocto-queue: + specifier: ^1.2.1 + version: 1.2.1 devDependencies: '@ianvs/prettier-plugin-sort-imports': specifier: ^4.3.1 @@ -2916,6 +2919,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.1: + resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} + engines: {node: '>=12.20'} + yoctocolors-cjs@2.1.2: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} @@ -5899,4 +5906,6 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.1: {} + yoctocolors-cjs@2.1.2: {} diff --git a/web/src/api/vm.ts b/web/src/api/vm.ts index 26550dc..7c1f015 100644 --- a/web/src/api/vm.ts +++ b/web/src/api/vm.ts @@ -78,8 +78,8 @@ export function disableHdmi() { } // set HDMI state -export function setHdmiState(state: string) { - if (state === 'enabled') { +export function setHdmiState(enabled: boolean) { + if (enabled) { return enableHdmi(); } return disableHdmi(); diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 805a18c..534d4f0 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -64,9 +64,7 @@ const en = { frameDetect: 'Frame Detect', frameDetectTip: "Calculate the difference between frames. Stop transmitting video stream when no changes are detected on the remote host's screen.", - resetHdmi: 'Reset HDMI', - enableHdmi: 'Enable HDMI', - disableHdmi: 'Disable HDMI', + resetHdmi: 'Reset HDMI' }, keyboard: { title: 'Keyboard', @@ -75,8 +73,7 @@ const en = { placeholder: 'Please input', submit: 'Submit', virtual: 'Keyboard', - ctrlaltdel: 'Ctrl+Alt+Del', - layout: 'Keyboard Layout' + ctrlaltdel: 'Ctrl+Alt+Del' }, mouse: { title: 'Mouse', @@ -90,6 +87,9 @@ const en = { mode: 'Mouse mode', absolute: 'Absolute mode', relative: 'Relative mode', + speed: 'Wheel speed', + fast: 'Fast', + slow: 'Slow', requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.', resetHid: 'Reset HID', hidOnly: { @@ -213,8 +213,7 @@ const en = { menuBar: 'Menu Bar', menuBarDesc: 'Display icons in the menu bar', webTitle: 'Web Title', - webTitleDesc: 'Customize the web page title', - keyboardLayout: 'Keyboard Layout' + webTitleDesc: 'Customize the web page title' }, device: { title: 'Device', @@ -262,6 +261,9 @@ const en = { description: 'Enable mDNS discovery service', tip: "Turning it off if it's not needed" }, + hdmi: { + description: 'Enable HDMI/monitor output' + }, hidOnly: 'HID-Only Mode', disk: 'Virtual Disk', diskDesc: 'Mount virtual U-disk on the remote host', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 0240ae4..eea448d 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -85,6 +85,9 @@ const zh = { mode: '鼠标模式', absolute: '绝对模式', relative: '相对模式', + speed: '滚轮速度', + fast: '快', + slow: '慢', requestPointer: '正在使用鼠标相对模式,请点击桌面获取鼠标指针。', resetHid: '重置 HID', hidOnly: { @@ -238,6 +241,9 @@ const zh = { description: '启用 mDNS 发现服务', tip: '如果您未使用此功能,建议将其关闭' }, + hdmi: { + description: '启用 HDMI/显示器 输出功能' + }, hidOnly: 'HID-Only 模式', disk: '虚拟U盘', diskDesc: '在远程主机中挂载虚拟U盘', diff --git a/web/src/jotai/hdmi.ts b/web/src/jotai/hdmi.ts deleted file mode 100644 index eeca5e2..0000000 --- a/web/src/jotai/hdmi.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { atom } from 'jotai'; - -// hid mode: normal or hid-only -export const hdmiEnabledAtom = atom<'enabled' | 'disabled'>('enabled'); diff --git a/web/src/jotai/mouse.ts b/web/src/jotai/mouse.ts index 68d2fa5..691f03e 100644 --- a/web/src/jotai/mouse.ts +++ b/web/src/jotai/mouse.ts @@ -6,5 +6,8 @@ export const mouseStyleAtom = atom('cursor-default'); // mouse mode: absolute or relative export const mouseModeAtom = atom('absolute'); +// mouse scroll interval (unit: ms) +export const scrollIntervalAtom = atom(0); + // hid mode: normal or hid-only export const hidModeAtom = atom<'normal' | 'hid-only'>('normal'); diff --git a/web/src/jotai/screen.ts b/web/src/jotai/screen.ts index a49cec6..16f75a2 100644 --- a/web/src/jotai/screen.ts +++ b/web/src/jotai/screen.ts @@ -2,6 +2,8 @@ import { atom } from 'jotai'; import { Resolution } from '@/types'; +export const isHdmiEnabledAtom = atom(true); + // video mode // direct: stream H.264 over HTTP // h264: stream H.264 over WebRTC diff --git a/web/src/lib/localstorage.ts b/web/src/lib/localstorage.ts index 19ffe10..b6cfef0 100644 --- a/web/src/lib/localstorage.ts +++ b/web/src/lib/localstorage.ts @@ -9,8 +9,10 @@ const GOP_KEY = 'nano-kvm-gop'; const FRAME_DETECT_KEY = 'nano-kvm-frame-detect'; const MOUSE_STYLE_KEY = 'nano-kvm-mouse-style'; const MOUSE_MODE_KEY = 'nano-kvm-mouse-mode'; +const MOUSE_SCROLL_INTERVAL_KEY = 'nanokvm-kvm-mouse-scroll-interval'; const SKIP_UPDATE_KEY = 'nano-kvm-check-update'; -const KEYBOARD_LAYOUT_KEY = 'nano-kvm-keyboard-layout'; +const KEYBOARD_SYSTEM_KEY = 'nano-kvm-keyboard-system'; +const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language'; const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password'; const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items'; const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm'; @@ -129,6 +131,15 @@ export function setMouseMode(mouse: string) { localStorage.setItem(MOUSE_MODE_KEY, mouse); } +export function getMouseScrollInterval() { + const interval = localStorage.getItem(MOUSE_SCROLL_INTERVAL_KEY); + return interval ? Number(interval) : null; +} + +export function setMouseScrollInterval(interval: number): void { + localStorage.setItem(MOUSE_SCROLL_INTERVAL_KEY, String(interval)); +} + export function getSkipUpdate() { const skip = getWithExpiry(SKIP_UPDATE_KEY); return skip === 'true'; @@ -139,17 +150,20 @@ export function setSkipUpdate(skip: boolean) { setWithExpiry(SKIP_UPDATE_KEY, String(skip), expiry); } -export function setKeyboardLayout(layout: string) { - localStorage.setItem(KEYBOARD_LAYOUT_KEY, layout); +export function setKeyboardSystem(system: string) { + localStorage.setItem(KEYBOARD_SYSTEM_KEY, system); } -export function getKeyboardLayout(): string { - // Return 'azerty' or 'qwerty' (or 'qwerty' if not defined) - const layout = localStorage.getItem(KEYBOARD_LAYOUT_KEY); - if (layout === 'azerty' || layout === 'mac' || layout === 'rus') { - return layout; - } - return 'default'; +export function getKeyboardSystem() { + return localStorage.getItem(KEYBOARD_SYSTEM_KEY); +} + +export function setKeyboardLanguage(language: string) { + localStorage.setItem(KEYBOARD_LANGUAGE_KEY, language); +} + +export function getKeyboardLanguage() { + return localStorage.getItem(KEYBOARD_LANGUAGE_KEY); } export function setSkipModifyPassword(skip: boolean) { diff --git a/web/src/pages/desktop/keyboard/virtual-keyboard.tsx b/web/src/pages/desktop/keyboard/virtual-keyboard.tsx index 383c24c..2349619 100644 --- a/web/src/pages/desktop/keyboard/virtual-keyboard.tsx +++ b/web/src/pages/desktop/keyboard/virtual-keyboard.tsx @@ -1,16 +1,18 @@ import { useEffect, useRef, useState } from 'react'; -import { XIcon } from 'lucide-react'; +import { AppleOutlined, WindowsOutlined } from '@ant-design/icons'; import clsx from 'clsx'; import { useAtom } from 'jotai'; +import { XIcon } from 'lucide-react'; import Keyboard, { KeyboardButtonTheme } from 'react-simple-keyboard'; import { Drawer } from 'vaul'; import 'react-simple-keyboard/build/css/index.css'; import '@/assets/styles/keyboard.css'; +import { ConfigProvider, Segmented, Select, theme } from 'antd'; import { useMediaQuery } from 'react-responsive'; -import { getKeyboardLayout } from '@/lib/localstorage.ts'; +import * as storage from '@/lib/localstorage.ts'; import { client } from '@/lib/websocket.ts'; import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts'; @@ -24,50 +26,60 @@ import { specialKeyMap } from './virtual-keys.ts'; -// Helper function to map keys per active layout -function getKeyCode(key: string, layout: string) { - // AZERTY: map < > key (next to left-shift) to OEM_102 usage (100) - if (layout === 'azerty' && key === 'Backquote_azerty') { - return KeyboardCodes.get('Backquote_azerty'); - } - - // AZERTY: swap A↔Q and Z↔W on French physical positions - if (layout === 'azerty' && key.endsWith('_azerty')) { - const base = key.replace('_azerty', ''); - if (base === 'KeyA') return KeyboardCodes.get('KeyQ'); - if (base === 'KeyQ') return KeyboardCodes.get('KeyA'); - if (base === 'KeyZ') return KeyboardCodes.get('KeyW'); - if (base === 'KeyW') return KeyboardCodes.get('KeyZ'); - // all other labels use their own code - return KeyboardCodes.get(base); - } - - // default (QWERTY / Mac / Rus) - return KeyboardCodes.get(key); -} - export const VirtualKeyboard = () => { const isBigScreen = useMediaQuery({ minWidth: 850 }); + const [isKeyboardOpen, setIsKeyboardOpen] = useAtom(isKeyboardOpenAtom); - const [layout, setLayout] = useState(getKeyboardLayout() || 'default'); + + const [keyboardLayout, setKeyboardLayout] = useState('default'); + const [keyboardSystem, setKeyboardSystem] = useState('win'); + const [keyboardLanguage, setKeyboardLanguage] = useState('en'); const [activeModifierKeys, setActiveModifierKeys] = useState([]); + const keyboardRef = useRef(null); - // Update the layout when it changes in settings - useEffect(() => { - // Function to check if the layout has changed - const checkLayout = () => { - const currentLayout = getKeyboardLayout() || 'default'; - if (currentLayout !== layout) { - setLayout(currentLayout); - } - }; + const systems = [ + { value: 'win', icon: }, + { value: 'mac', icon: } + ]; - // Check for layout changes every time the keyboard is opened - if (isKeyboardOpen) { - checkLayout(); + const languages = [ + { value: 'en', label: 'English' }, + { value: 'fr', label: 'French' }, + { value: 'ru', label: 'Russian' } + ]; + + useEffect(() => { + const system = storage.getKeyboardSystem(); + if (system && ['win', 'mac'].includes(system)) { + setKeyboardSystem(system); } - }, [isKeyboardOpen, layout]); + + const language = storage.getKeyboardLanguage(); + if (language && languages.some((lng) => lng.value === language)) { + setKeyboardLanguage(language); + } + }, []); + + useEffect(() => { + const layoutMap = new Map([ + ['en', 'default'], + ['ru', 'rus'], + ['fr', 'azerty'] + ]); + + if (keyboardLanguage === 'en' && keyboardSystem === 'mac') { + setKeyboardLayout('mac'); + return; + } + + if (layoutMap.has(keyboardLanguage)) { + setKeyboardLayout(layoutMap.get(keyboardLanguage)!); + return; + } + + setKeyboardLayout('default'); + }, [keyboardSystem, keyboardLanguage]); function onKeyPress(key: string) { if (modifierKeys.includes(key)) { @@ -92,9 +104,7 @@ export const VirtualKeyboard = () => { } function sendKeydown(key: string) { - const specialKey = specialKeyMap.get(key); - const code = getKeyCode(specialKey ? specialKey : key, layout); - + const code = getKeyCode(key); if (!code) { console.log('unknown code: ', key); return; @@ -105,6 +115,26 @@ export const VirtualKeyboard = () => { client.send([1, code, ...modifiers]); } + function getKeyCode(key: string) { + // AZERTY: swap A↔Q and Z↔W on French physical positions + if (keyboardLanguage === 'fr' && key.endsWith('_azerty')) { + const base = key.replace('_azerty', ''); + if (base === 'KeyA') return KeyboardCodes.get('KeyQ'); + if (base === 'KeyQ') return KeyboardCodes.get('KeyA'); + if (base === 'KeyZ') return KeyboardCodes.get('KeyW'); + if (base === 'KeyW') return KeyboardCodes.get('KeyZ'); + // all other labels use their own code + return KeyboardCodes.get(base); + } + + const specialKey = specialKeyMap.get(key); + if (specialKey) { + return KeyboardCodes.get(specialKey); + } + + return KeyboardCodes.get(key); + } + function sendKeyup() { sendModifierKeyUp(); client.send([1, 0, 0, 0, 0, 0]); @@ -148,6 +178,16 @@ export const VirtualKeyboard = () => { setActiveModifierKeys([]); } + function selectSystem(system: string) { + setKeyboardSystem(system); + storage.setKeyboardSystem(system); + } + + function selectLanguage(language: string) { + setKeyboardLanguage(language); + storage.setKeyboardLanguage(language); + } + function getButtonTheme(): KeyboardButtonTheme[] { const theme = [{ class: 'hg-double', buttons: doubleKeys.join(' ') }]; @@ -170,15 +210,30 @@ export const VirtualKeyboard = () => { > {/* header */}
-
- {layout === 'default' - ? 'QWERTY (Win)' - : layout === 'mac' - ? 'QWERTY (Mac)' - : layout === 'azerty' - ? 'AZERTY (Win)' - : 'Russian'} -
+ +
+ -
- ); -}; diff --git a/web/src/pages/desktop/menu/settings/device/hdmi.tsx b/web/src/pages/desktop/menu/settings/device/hdmi.tsx new file mode 100644 index 0000000..4ab22ea --- /dev/null +++ b/web/src/pages/desktop/menu/settings/device/hdmi.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react'; +import { Switch } from 'antd'; +import { useAtom } from 'jotai'; +import { useTranslation } from 'react-i18next'; + +import * as api from '@/api/vm.ts'; +import { isHdmiEnabledAtom } from '@/jotai/screen.ts'; + +export const Hdmi = () => { + const { t } = useTranslation(); + + const [isHdmiEnabled, setIsHdmiEnabled] = useAtom(isHdmiEnabledAtom); + + const [isPcie, setIsPcie] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + getHardware(); + getHdmiState(); + }, []); + + async function getHardware() { + const rsp = await api.getHardware(); + if (rsp.code !== 0) { + return; + } + + setIsPcie(rsp.data?.version === 'PCIE'); + } + + async function getHdmiState() { + setIsLoading(true); + + const rsp = await api.getHdmiState(); + if (rsp.code === 0) { + setIsHdmiEnabled(rsp.data.enabled); + } + + setIsLoading(false); + } + + async function setHdmiState() { + if (isLoading) return; + setIsLoading(true); + + const enabled = !isHdmiEnabled; + + const rsp = await api.setHdmiState(enabled); + if (rsp.code !== 0) { + setIsLoading(false); + return; + } + + setTimeout(() => { + setIsHdmiEnabled(enabled); + setIsLoading(false); + }, 1000); + } + + return ( + <> + {isPcie && ( +
+
+ HDMI + + + {t('settings.device.hdmi.description')} + +
+ + +
+ )} + + ); +}; diff --git a/web/src/pages/desktop/menu/settings/device/index.tsx b/web/src/pages/desktop/menu/settings/device/index.tsx index 00d1c31..cb818d9 100644 --- a/web/src/pages/desktop/menu/settings/device/index.tsx +++ b/web/src/pages/desktop/menu/settings/device/index.tsx @@ -7,6 +7,7 @@ import * as api from '@/api/hid.ts'; import { hidModeAtom } from '@/jotai/mouse.ts'; import { Advanced } from './advanced'; +import { Hdmi } from './hdmi.tsx'; import { HidMode } from './hid-mode.tsx'; import { Mdns } from './mdns.tsx'; import { MouseJiggler } from './mouse-jiggler.tsx'; @@ -39,6 +40,7 @@ export const Device = () => { + {hidMode === 'normal' ? : }
diff --git a/web/src/pages/desktop/mouse/absolute.tsx b/web/src/pages/desktop/mouse/absolute.tsx index a615068..11920ca 100644 --- a/web/src/pages/desktop/mouse/absolute.tsx +++ b/web/src/pages/desktop/mouse/absolute.tsx @@ -1,13 +1,17 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useAtomValue } from 'jotai'; import { client } from '@/lib/websocket.ts'; +import { scrollIntervalAtom } from '@/jotai/mouse.ts'; import { resolutionAtom } from '@/jotai/screen.ts'; import { MouseButton, MouseEvent } from './constants'; export const Absolute = () => { const resolution = useAtomValue(resolutionAtom); + const scrollInterval = useAtomValue(scrollIntervalAtom); + + const lastScrollTimeRef = useRef(0); // listen mouse events useEffect(() => { @@ -74,6 +78,12 @@ export const Absolute = () => { const delta = Math.floor(event.deltaY); if (delta === 0) return; + const currentTime = Date.now(); + if (currentTime - lastScrollTimeRef.current < scrollInterval) { + return; + } + lastScrollTimeRef.current = currentTime; + const data = [2, MouseEvent.Scroll, 0, 0, delta]; client.send(data); } @@ -82,10 +92,11 @@ export const Absolute = () => { canvas.removeEventListener('mousemove', handleMouseMove); canvas.removeEventListener('mousedown', handleMouseDown); canvas.removeEventListener('mouseup', handleMouseUp); + canvas.removeEventListener('wheel', handleWheel); canvas.removeEventListener('click', disableEvent); canvas.removeEventListener('contextmenu', disableEvent); }; - }, [resolution]); + }, [resolution, scrollInterval]); // disable default events function disableEvent(event: any) { diff --git a/web/src/pages/desktop/mouse/relative.tsx b/web/src/pages/desktop/mouse/relative.tsx index a527e42..e50671c 100644 --- a/web/src/pages/desktop/mouse/relative.tsx +++ b/web/src/pages/desktop/mouse/relative.tsx @@ -4,19 +4,21 @@ import { useAtomValue } from 'jotai'; import { useTranslation } from 'react-i18next'; import { client } from '@/lib/websocket.ts'; +import { scrollIntervalAtom } from '@/jotai/mouse.ts'; import { resolutionAtom } from '@/jotai/screen.ts'; import { MouseButton, MouseEvent } from './constants'; export const Relative = () => { const { t } = useTranslation(); + const [messageApi, contextHolder] = message.useMessage(); const resolution = useAtomValue(resolutionAtom); + const scrollInterval = useAtomValue(scrollIntervalAtom); const isLockedRef = useRef(false); const buttonRef = useRef(MouseButton.None); - - const [messageApi, contextHolder] = message.useMessage(); + const lastScrollTimeRef = useRef(0); // listen mouse events useEffect(() => { @@ -113,6 +115,12 @@ export const Relative = () => { const delta = Math.floor(event.deltaY); if (delta === 0) return; + const currentTime = Date.now(); + if (currentTime - lastScrollTimeRef.current < scrollInterval) { + return; + } + lastScrollTimeRef.current = currentTime; + const data = [2, MouseEvent.Scroll, 0, 0, delta]; client.send(data); } @@ -124,9 +132,10 @@ export const Relative = () => { canvas.removeEventListener('mousemove', handleMouseMove); canvas.removeEventListener('mousedown', handleMouseDown); canvas.removeEventListener('mouseup', handleMouseUp); + canvas.removeEventListener('wheel', handleWheel); canvas.removeEventListener('contextmenu', disableEvent); }; - }, [resolution]); + }, [resolution, scrollInterval]); // disable default events function disableEvent(event: any) { diff --git a/web/src/pages/desktop/screen/h264-direct.tsx b/web/src/pages/desktop/screen/h264-direct.tsx index 6412b67..b5bf50b 100644 --- a/web/src/pages/desktop/screen/h264-direct.tsx +++ b/web/src/pages/desktop/screen/h264-direct.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'; import clsx from 'clsx'; import { useAtomValue } from 'jotai'; import { w3cwebsocket as W3cWebSocket } from 'websocket'; +import Queue from 'yocto-queue'; import { getBaseUrl } from '@/lib/service.ts'; import { mouseStyleAtom } from '@/jotai/mouse'; @@ -11,11 +12,13 @@ export const H264Direct = () => { const resolution = useAtomValue(resolutionAtom); const mouseStyle = useAtomValue(mouseStyleAtom); - const canvasRef = useRef(null); - const decoderRef = useRef(null); - const frameQueueRef = useRef([]); + const canvasRef = useRef(); const renderingRef = useRef(false); + const decoderRef = useRef(null); + const frameQueue = new Queue(); + + // init websocket useEffect(() => { if (!window.VideoDecoder) { console.log('Error: WebCodecs API not supported.'); @@ -42,11 +45,9 @@ export const H264Direct = () => { } }; - ws.onerror = (err) => { - console.log(err); + ws.onerror = () => { resetDecoder(); }; - ws.onclose = () => { resetDecoder(); }; @@ -59,6 +60,7 @@ export const H264Direct = () => { }; }, []); + // init video decoder function initializeDecoder() { if (!window.VideoDecoder) { return; @@ -69,7 +71,11 @@ export const H264Direct = () => { const init = { output: (frame: VideoFrame) => { - frameQueueRef.current.push(frame); + frameQueue.enqueue(frame); + if (frameQueue.size >= 10) { + frameQueue.dequeue()?.close(); + } + if (!renderingRef.current) { requestAnimationFrame(processFrameQueue); } @@ -94,6 +100,7 @@ export const H264Direct = () => { } } + // decode video chunk function decode(message: any) { const byteString = atob(message.data); const byteArray = new Uint8Array(byteString.length); @@ -108,7 +115,7 @@ export const H264Direct = () => { }); try { - decoderRef.current.decode(chunk); + decoderRef.current?.decode(chunk); } catch (err: any) { if (err.name === 'TypeError' || err.message.includes('configured')) { resetDecoder(); @@ -116,22 +123,18 @@ export const H264Direct = () => { } } - const processFrameQueue = () => { + function processFrameQueue() { renderingRef.current = true; - const frame = frameQueueRef.current.shift(); + + const frame = frameQueue.dequeue(); if (frame) { renderFrame(frame); } - if (frameQueueRef.current.length === 0) { - renderingRef.current = false; - return; - } - requestAnimationFrame(processFrameQueue); - }; + } - const renderFrame = (frame: any) => { + function renderFrame(frame: VideoFrame) { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!canvas || !ctx) { @@ -146,9 +149,10 @@ export const H264Direct = () => { ctx.drawImage(frame, 0, 0, canvas.width, canvas.height); frame.close(); - }; + } - const resetDecoder = () => { + // reset video decoder + function resetDecoder() { if (decoderRef.current && decoderRef.current.state !== 'closed') { try { decoderRef.current.close(); @@ -160,9 +164,8 @@ export const H264Direct = () => { decoderRef.current = null; renderingRef.current = false; - frameQueueRef.current.forEach((frame) => frame.close()); - frameQueueRef.current = []; - }; + Array.from(frameQueue.drain()).forEach((frame) => frame.close()); + } return (
diff --git a/web/src/pages/settings/keyboard-layout.tsx b/web/src/pages/settings/keyboard-layout.tsx deleted file mode 100644 index cd244e3..0000000 --- a/web/src/pages/settings/keyboard-layout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useState } from 'react'; -import { Select } from 'antd'; -import { useTranslation } from 'react-i18next'; - -import { getKeyboardLayout, setKeyboardLayout } from '@/lib/localstorage.ts'; - -export const KeyboardLayoutSetting = () => { - const { t } = useTranslation(); - const [layout, setLayout] = useState(getKeyboardLayout() || 'default'); - - function handleChange(value: string) { - setLayout(value); - setKeyboardLayout(value); - } - - return ( -
- -