Fix direct H.264 issues and optimize UI

feat: support custom mouse wheel speed
fix: prevent direct H.264 stream buffer overflow and replay
style: optimize virtual keyboard layout
style: optimize menu bar and settings styling
This commit is contained in:
wj-xiao
2025-05-22 11:31:08 +08:00
parent ea9068fd0d
commit 01e28f10ae
28 changed files with 439 additions and 342 deletions

View File

@@ -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"`
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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",

9
web/pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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();

View File

@@ -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',

View File

@@ -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盘',

View File

@@ -1,4 +0,0 @@
import { atom } from 'jotai';
// hid mode: normal or hid-only
export const hdmiEnabledAtom = atom<'enabled' | 'disabled'>('enabled');

View File

@@ -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');

View File

@@ -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

View File

@@ -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) {

View File

@@ -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<string[]>([]);
const keyboardRef = useRef<any>(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: <WindowsOutlined /> },
{ value: 'mac', icon: <AppleOutlined /> }
];
// 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 */}
<div className="flex items-center justify-between px-3 py-1">
<div className="keyboard-header text-sm font-medium px-2">
{layout === 'default'
? 'QWERTY (Win)'
: layout === 'mac'
? 'QWERTY (Mac)'
: layout === 'azerty'
? 'AZERTY (Win)'
: 'Russian'}
</div>
<ConfigProvider
theme={{
algorithm: theme.defaultAlgorithm
}}
>
<div className="flex items-center space-x-5">
<Select
size="small"
style={{ minWidth: 90 }}
defaultValue={keyboardLanguage}
options={languages}
onChange={selectLanguage}
/>
{keyboardLanguage === 'en' && (
<Segmented
size="small"
options={systems}
value={keyboardSystem}
onChange={selectSystem}
/>
)}
</div>
</ConfigProvider>
<div className="flex w-[100px] items-center justify-end">
<div
@@ -199,7 +254,7 @@ export const VirtualKeyboard = () => {
keyboardRef={(r) => (keyboardRef.current = r)}
onKeyPress={onKeyPress}
onKeyReleased={onKeyReleased}
layoutName={layout}
layoutName={keyboardLayout}
{...keyboardOptions}
/>

View File

@@ -1,3 +1,5 @@
// TODO: refactor it
// main keys
export const keyboardOptions = {
theme: 'simple-keyboard hg-theme-default',
@@ -119,7 +121,7 @@ export const keyboardOptions = {
RusBracketLeft: 'Х',
RusBracketRight: 'Ъ',
RusBackslash: '/<br>\\',
RusA: 'Ф',
RusS: 'Ы',
RusD: 'В',
@@ -145,59 +147,59 @@ export const keyboardOptions = {
// AZERTY specific display keys
// Row 1
'Backquote_azerty': '&#60;<br/>&#62;',
'Digit1_azerty': '&<br/>1',
'Digit2_azerty': 'é<br/>2',
'Digit3_azerty': '"<br/>#',
'Digit4_azerty': '\'<br/>{',
'Digit5_azerty': '(<br/>[',
'Digit6_azerty': '-<br/>|',
'Digit7_azerty': 'è<br/>`',
'Digit8_azerty': '_<br/>\\',
'Digit9_azerty': 'ç<br/>^',
'Digit0_azerty': 'à<br/>@',
'Minus_azerty': ')<br/>]',
'Equal_azerty': '=<br/>}',
Backquote_azerty: '&#60;<br/>&#62;',
Digit1_azerty: '&<br/>1',
Digit2_azerty: 'é<br/>2',
Digit3_azerty: '"<br/>#',
Digit4_azerty: "'<br/>{",
Digit5_azerty: '(<br/>[',
Digit6_azerty: '-<br/>|',
Digit7_azerty: 'è<br/>`',
Digit8_azerty: '_<br/>\\',
Digit9_azerty: 'ç<br/>^',
Digit0_azerty: 'à<br/>@',
Minus_azerty: ')<br/>]',
Equal_azerty: '=<br/>}',
// Row 2
'KeyA_azerty': 'A',
'KeyZ_azerty': 'Z',
'KeyE_azerty': 'E<br/>€',
'KeyR_azerty': 'R',
'KeyT_azerty': 'T',
'KeyY_azerty': 'Y',
'KeyU_azerty': 'U',
'KeyI_azerty': 'I',
'KeyO_azerty': 'O',
'KeyP_azerty': 'P',
'BracketLeft_azerty': '¨<br/>^',
'BracketRight_azerty': '£<br/>$',
'Backslash_azerty': 'µ<br/>*',
KeyA_azerty: 'A',
KeyZ_azerty: 'Z',
KeyE_azerty: 'E<br/>€',
KeyR_azerty: 'R',
KeyT_azerty: 'T',
KeyY_azerty: 'Y',
KeyU_azerty: 'U',
KeyI_azerty: 'I',
KeyO_azerty: 'O',
KeyP_azerty: 'P',
BracketLeft_azerty: '¨<br/>^',
BracketRight_azerty: '£<br/>$',
Backslash_azerty: 'µ<br/>*',
// Row 3
'KeyQ_azerty': 'Q',
'KeyS_azerty': 'S',
'KeyD_azerty': 'D',
'KeyF_azerty': 'F',
'KeyG_azerty': 'G',
'KeyH_azerty': 'H',
'KeyJ_azerty': 'J',
'KeyK_azerty': 'K',
'KeyL_azerty': 'L',
'Semicolon_azerty': 'M',
'Quote_azerty': '%<br/>ù',
KeyQ_azerty: 'Q',
KeyS_azerty: 'S',
KeyD_azerty: 'D',
KeyF_azerty: 'F',
KeyG_azerty: 'G',
KeyH_azerty: 'H',
KeyJ_azerty: 'J',
KeyK_azerty: 'K',
KeyL_azerty: 'L',
Semicolon_azerty: 'M',
Quote_azerty: '%<br/>ù',
// Row 4
'KeyW_azerty': 'W',
'KeyX_azerty': 'X',
'KeyC_azerty': 'C',
'KeyV_azerty': 'V',
'KeyB_azerty': 'B',
'KeyN_azerty': 'N',
'KeyM_azerty': '?<br/>,',
'Comma_azerty': '.<br/>;',
'Period_azerty': '/<br/>:',
'Slash_azerty': '§<br/>!',
KeyW_azerty: 'W',
KeyX_azerty: 'X',
KeyC_azerty: 'C',
KeyV_azerty: 'V',
KeyB_azerty: 'B',
KeyN_azerty: 'N',
KeyM_azerty: '?<br/>,',
Comma_azerty: '.<br/>;',
Period_azerty: '/<br/>:',
Slash_azerty: '§<br/>!'
},
// Enable layout-specific display
mergeDisplay: true,
@@ -205,7 +207,7 @@ export const keyboardOptions = {
default: 'default',
shift: 'shift',
azerty: 'azerty'
},
}
// ...remaining options...
};
@@ -313,4 +315,4 @@ export const doubleKeys = [
'Comma',
'Period',
'Slash'
];
];

View File

@@ -5,18 +5,21 @@ import { MouseIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as ls from '@/lib/localstorage';
import { mouseModeAtom, mouseStyleAtom } from '@/jotai/mouse';
import { mouseModeAtom, mouseStyleAtom, scrollIntervalAtom } from '@/jotai/mouse';
import { MenuItem } from '@/components/menu-item.tsx';
import { Cursor } from './cursor.tsx';
import { HidMode } from './hid-mode.tsx';
import { MouseMode } from './mouse-mode.tsx';
import { ResetHid } from './reset-hid.tsx';
import { Speed } from './speed.tsx';
export const Mouse = () => {
const { t } = useTranslation();
const setMouseStyle = useSetAtom(mouseStyleAtom);
const setMouseMode = useSetAtom(mouseModeAtom);
const setScrollInterval = useSetAtom(scrollIntervalAtom);
useEffect(() => {
const mouseStyle = ls.getMouseStyle();
@@ -28,12 +31,18 @@ export const Mouse = () => {
if (mouseMode) {
setMouseMode(mouseMode);
}
const interval = ls.getMouseScrollInterval();
if (interval) {
setScrollInterval(interval);
}
}, []);
const content = (
<div className="flex flex-col space-y-1">
<Cursor />
<MouseMode />
<Speed />
<Divider style={{ margin: '10px 0' }} />
<HidMode />

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { Popover, Slider } from 'antd';
import { useAtom } from 'jotai';
import { GaugeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as storage from '@/lib/localstorage.ts';
import { scrollIntervalAtom } from '@/jotai/mouse.ts';
const MAX_INTERVAL = 300;
export const Speed = () => {
const { t } = useTranslation();
const [scrollInterval, setScrollInterval] = useAtom(scrollIntervalAtom);
const [scrollSpeed, setScrollSpeed] = useState(100);
useEffect(() => {
const speed = interval2Speed(scrollInterval);
setScrollSpeed(speed);
}, [scrollInterval]);
function update(speed: number): void {
const interval = speed2Interval(speed);
setScrollInterval(interval);
storage.setMouseScrollInterval(interval);
}
function interval2Speed(interval: number) {
if (interval === MAX_INTERVAL) {
return 0;
}
return ((MAX_INTERVAL - interval) * 100) / MAX_INTERVAL;
}
function speed2Interval(speed: number) {
return MAX_INTERVAL - speed * (MAX_INTERVAL / 100);
}
const content = (
<div className="h-[150px] w-[60px] py-3">
<Slider
vertical
marks={{
0: <span>{t('mouse.slow')}</span>,
100: <span>{t('mouse.fast')}</span>
}}
range={false}
included={false}
step={10}
defaultValue={scrollSpeed}
onChange={update}
/>
</div>
);
return (
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
<GaugeIcon size={18} />
<span>{t('mouse.speed')}</span>
</div>
</Popover>
);
};

View File

@@ -1,86 +0,0 @@
import { useEffect, useState } from 'react';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { PenIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
import { hdmiEnabledAtom } from '@/jotai/hdmi.ts';
export const HdmiState = () => {
const { t } = useTranslation();
const [hdmiMode, setHdmiMode] = useAtom(hdmiEnabledAtom);
const [isPcie, setIsPcie] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [_, setErrMsg] = useState('');
useEffect(() => {
getHdmiState();
api.getHardware().then((rsp) => {
if (rsp.code === 0) {
setIsPcie(rsp.data?.version === 'PCIE');
}
});
}, []);
function getHdmiState() {
setIsLoading(true);
api
.getHdmiState()
.then((rsp) => {
if (rsp.code !== 0) {
setErrMsg(rsp.msg);
return;
}
setHdmiMode(rsp.data.state);
})
.finally(() => {
setIsLoading(false);
});
}
function updateHDMIState() {
if (isLoading) return;
setIsLoading(true);
const mode = hdmiMode === 'enabled' ? 'disabled' : 'enabled';
api
.setHdmiState(mode)
.then((rsp) => {
setIsLoading(false);
if (rsp.code !== 0) {
console.log(rsp.msg);
} else {
setHdmiMode(mode);
}
})
.catch((err) => {
console.log(err);
setIsLoading(false);
});
}
return (
<>
{isPcie ? (
<div
className={clsx(
'flex h-[30px] cursor-pointer select-none items-center space-x-2 rounded px-3 hover:bg-neutral-700/70',
'text-neutral-300'
)}
onClick={updateHDMIState}
>
<PenIcon size={18} />
<span>{hdmiMode === 'enabled' ? t('screen.disableHdmi') : t('screen.enableHdmi')}</span>
</div>
) : (
<></>
)}
</>
);
};

View File

@@ -16,7 +16,6 @@ import { Quality } from './quality';
import { Reset } from './reset.tsx';
import { Resolution } from './resolution';
import { VideoMode } from './video-mode.tsx';
import { HdmiState } from './hdmi-state.tsx';
export const Screen = () => {
const { t } = useTranslation();
@@ -77,7 +76,6 @@ export const Screen = () => {
<Fps fps={fps} setFps={setFps} />
{videoMode === 'mjpeg' ? <FrameDetect /> : <Gop gop={gop} setGop={setGop} />}
<Reset />
<HdmiState />
</div>
);

View File

@@ -32,7 +32,7 @@ export const Reset = () => {
return (
<>
{isPcie ? (
{isPcie && (
<div
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70"
onClick={reset}
@@ -43,8 +43,6 @@ export const Reset = () => {
/>
<span className="select-none text-sm">{t('screen.resetHdmi')}</span>
</div>
) : (
<></>
)}
</>
);

View File

@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
import { Language } from './language.tsx';
import { MenuBar } from './menu-bar.tsx';
import { WebTitle } from './web-title.tsx';
import { KeyboardLayout } from './keyboard-layout.tsx';
export const Appearance = () => {
const { t } = useTranslation();
@@ -17,7 +16,6 @@ export const Appearance = () => {
<div className="flex flex-col space-y-6">
<Language />
<WebTitle />
<KeyboardLayout />
</div>
<Divider />

View File

@@ -1,50 +0,0 @@
import { useEffect, useState } from 'react';
import { Select } from 'antd';
import { KeyboardIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as ls from '@/lib/localstorage.ts';
export const KeyboardLayout = () => {
const { t } = useTranslation();
const [layout, setLayout] = useState<string>('default');
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const savedLayout = ls.getKeyboardLayout();
if (savedLayout) {
setLayout(savedLayout);
}
setIsLoading(false);
}, []);
const options = [
{ value: 'default', label: 'QWERTY (US)' },
{ value: 'azerty', label: 'AZERTY (FR)' },
{ value: 'mac', label: 'QWERTY (Mac)' },
{ value: 'rus', label: 'Russian' }
];
function changeLayout(value: string) {
if (layout === value) return;
setLayout(value);
ls.setKeyboardLayout(value);
}
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-1">
<KeyboardIcon size={16} />
<span>{t('settings.appearance.keyboardLayout', { defaultValue: 'Keyboard Layout' })}</span>
</div>
<Select
value={layout}
style={{ width: 180 }}
options={options}
loading={isLoading}
onChange={changeLayout}
/>
</div>
);
};

View File

@@ -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 && (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span>HDMI</span>
<span className="text-xs text-neutral-500">
{t('settings.device.hdmi.description')}
</span>
</div>
<Switch checked={isHdmiEnabled} loading={isLoading} onChange={setHdmiState} />
</div>
)}
</>
);
};

View File

@@ -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 = () => {
<Tls />
<Ssh />
<Mdns />
<Hdmi />
{hidMode === 'normal' ? <VirtualDevices /> : <HidMode />}
</div>

View File

@@ -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) {

View File

@@ -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>(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) {

View File

@@ -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<any>(null);
const decoderRef = useRef<any>(null);
const frameQueueRef = useRef<VideoFrame[]>([]);
const canvasRef = useRef<any>();
const renderingRef = useRef(false);
const decoderRef = useRef<VideoDecoder | null>(null);
const frameQueue = new Queue<VideoFrame>();
// 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 (
<div className="flex h-screen w-screen items-start justify-center xl:items-center">

View File

@@ -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 (
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">{t('settings.keyboard.layout')}</label>
<Select
value={layout}
onChange={handleChange}
options={[
{ value: 'default', label: 'QWERTY (Win)' },
{ value: 'mac', label: 'QWERTY (Mac)' },
{ value: 'azerty', label: 'AZERTY (Win)' }
]}
/>
</div>
);
};