Compare commits

...

15 Commits
2.2.7 ... 2.2.8

Author SHA1 Message Date
wj-xiao
a4978d93d6 update CHANGELOG.md 2025-05-22 14:13:18 +08:00
wj-xiao
01e28f10ae 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
2025-05-22 11:31:08 +08:00
wenjie
ea9068fd0d Merge pull request #520 from ethanperrine/main
Updated paste.go
2025-05-20 19:30:15 -07:00
wenjie
d85823abcf Merge pull request #508 from arbdevml/ru
Russian language file was updated in accordance with the new version.
2025-05-20 19:29:51 -07:00
wenjie
4c0027ffd3 Merge pull request #507 from arbdevml/uk
Ukrainian language file was updated in accordance with the new version.
2025-05-20 19:29:35 -07:00
wenjie
afb868af30 Merge pull request #501 from tpretz/hdmi-disable
Adding HDMI Enable/Disable Toggle for PCIe
2025-05-20 19:29:10 -07:00
wenjie
66b1d5dc30 Merge pull request #500 from felix068/feature/azerty-keyboard-layout
Feat: Add AZERTY layout (virtual keyboard) and keyboard layout settings
2025-05-20 19:27:48 -07:00
wenjie
7d8cdc74c6 Merge pull request #496 from kmw0410/main
revised some korean text
2025-05-20 19:25:11 -07:00
Quandale Dingle
809581df49 Update paste.go
Code now relies solely on the modifier byte within the main keyDown report to signal that shift is active. This is a more reliable approach.
2025-05-20 17:09:27 -04:00
Alexandro
5d47a8d48d Update ru.ts 2025-05-12 22:10:26 +00:00
Alexandro
6242f1931e Update uk.ts 2025-05-12 22:08:52 +00:00
Alexandro
b768219c18 Update uk.ts 2025-05-12 22:03:56 +00:00
Thomas Pressnell
6935923bd1 Adding HDMI Enable/Disable Toggle for PCIe 2025-05-10 19:41:25 +01:00
TPM-28
559e6e2556 Feat: Add AZERTY layout (virtual keyboard) and keyboard layout settings
This pull request introduces the following changes:

* **Added AZERTY Keyboard Layout:**
  Implemented support for the AZERTY keyboard layout in the virtual keyboard. This includes both the visual layout and the corresponding key code mappings.

* **Keyboard Layout Setting:**
  Added a new setting under *"Appearance"* to allow users to select their preferred keyboard layout (QWERTY Win, QWERTY Mac, AZERTY Win, Russian). The selected layout is saved to local storage and applied to the virtual keyboard.
2025-05-10 12:25:21 +02:00
kmw0410
f354fdfbf4 revised some korean text 2025-05-09 19:35:38 +09:00
29 changed files with 628 additions and 219 deletions

View File

@@ -1,3 +1,15 @@
## 2.2.8 [01e28f1](https://github.com/sipeed/NanoKVM/commit/01e28f10ae8b581d484bb6077ddfe7bbe4e57919) (2025-05-22)
* feat: add AZERTY virtual keyboard Layout (thanks to [@felix068](https://github.com/felix068))
* feat: add support for enabling/disabling HDMI output (PCIe version only) (thanks to [@tpretz](https://github.com/tpretz))
* feat: add support for custom mouse wheel speed
* fix: prevent direct H.264 stream buffer overflow and replay issues
* perf: improve keyboard paste performance (thanks to [@ethanperrine](https://github.com/ethanperrine))
* Localization
* update Korean translation (thanks to [@kmw0410](https://github.com/kmw0410))
* update Ukrainian translation (thanks to [@arbdevml](https://github.com/arbdevml))
* update Russian translation (thanks to [@arbdevml](https://github.com/arbdevml))
## 2.2.7 [e18ec22](https://github.com/sipeed/NanoKVM/commit/e18ec2219d22886529575d1fdaad5c320e05f5b2) (2025-05-08)
* feat: add HTTPS support

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,7 +35,11 @@ func vmRouter(r *gin.Engine) {
api.GET("/vm/oled", service.GetOLED) // get OLED configuration
api.POST("/vm/oled", service.SetOLED) // set OLED configuration
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi (pcie only)
// Only supported by PCIe version
api.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
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
api.GET("/vm/ssh", service.GetSSHState) // get SSH state
api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH

View File

@@ -21,152 +21,94 @@ type PasteReq struct {
func (s *Service) Paste(c *gin.Context) {
var req PasteReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
if len(req.Content) > 1024 {
rsp.ErrRsp(c, -2, "")
rsp.ErrRsp(c, -2, "content too long")
return
}
s.hid.kbMutex.Lock()
defer s.hid.kbMutex.Unlock()
keyUp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
for _, char := range req.Content {
key, ok := charMap[char]
if !ok {
log.Debugf("unknown key %c", char)
log.Debugf("unknown key '%c' (rune: %d)", char, char)
continue
}
keyDown := []byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00}
keyUp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
// only handle shift. Need to handle all modifiers?
if key.Modifiers > 0 {
keyShift := []byte{0x00, 0x00, byte(225), 0x00, 0x00, 0x00, 0x00, 0x00}
s.hid.Write(s.hid.g0, keyShift)
}
s.hid.Write(s.hid.g0, keyDown)
s.hid.Write(s.hid.g0, keyUp)
if key.Modifiers > 0 {
s.hid.Write(s.hid.g0, keyUp)
}
time.Sleep(50 * time.Millisecond)
}
s.hid.kbMutex.Unlock()
rsp.OkRsp(c)
log.Debugf("hid paste success, total %d", len(req.Content))
log.Debugf("hid paste success, total %d characters processed", len(req.Content))
}
var charMap = map[rune]Char{
97: {0, 4}, // a
98: {0, 5}, // b
99: {0, 6}, // c
100: {0, 7}, // d
101: {0, 8}, // e
102: {0, 9}, // f
103: {0, 10}, // g
104: {0, 11}, // h
105: {0, 12}, // i
106: {0, 13}, // j
107: {0, 14}, // k
108: {0, 15}, // l
109: {0, 16}, // m
110: {0, 17}, // n
111: {0, 18}, // o
112: {0, 19}, // p
113: {0, 20}, // q
114: {0, 21}, // r
115: {0, 22}, // s
116: {0, 23}, // t
117: {0, 24}, // u
118: {0, 25}, // v
119: {0, 26}, // w
120: {0, 27}, // x
121: {0, 28}, // y
122: {0, 29}, // z
// Lowercase letters
'a': {0, 4}, 'b': {0, 5}, 'c': {0, 6}, 'd': {0, 7}, 'e': {0, 8},
'f': {0, 9}, 'g': {0, 10}, 'h': {0, 11}, 'i': {0, 12}, 'j': {0, 13},
'k': {0, 14}, 'l': {0, 15}, 'm': {0, 16}, 'n': {0, 17}, 'o': {0, 18},
'p': {0, 19}, 'q': {0, 20}, 'r': {0, 21}, 's': {0, 22}, 't': {0, 23},
'u': {0, 24}, 'v': {0, 25}, 'w': {0, 26}, 'x': {0, 27}, 'y': {0, 28},
'z': {0, 29},
49: {0, 30}, // 1
50: {0, 31}, // 2
51: {0, 32}, // 3
52: {0, 33}, // 4
53: {0, 34}, // 5
54: {0, 35}, // 6
55: {0, 36}, // 7
56: {0, 37}, // 8
57: {0, 38}, // 9
48: {0, 39}, // 0
// Uppercase letters (Modifier 2 typically means Left Shift)
'A': {2, 4}, 'B': {2, 5}, 'C': {2, 6}, 'D': {2, 7}, 'E': {2, 8},
'F': {2, 9}, 'G': {2, 10}, 'H': {2, 11}, 'I': {2, 12}, 'J': {2, 13},
'K': {2, 14}, 'L': {2, 15}, 'M': {2, 16}, 'N': {2, 17}, 'O': {2, 18},
'P': {2, 19}, 'Q': {2, 20}, 'R': {2, 21}, 'S': {2, 22}, 'T': {2, 23},
'U': {2, 24}, 'V': {2, 25}, 'W': {2, 26}, 'X': {2, 27}, 'Y': {2, 28},
'Z': {2, 29},
10: {0, 40}, // Enter
9: {0, 43}, // Tab
32: {0, 44}, // Space
45: {0, 45}, // -
61: {0, 46}, // =
91: {0, 47}, // [
93: {0, 48}, // ]
92: {0, 49}, // \
// Numbers
'1': {0, 30}, '2': {0, 31}, '3': {0, 32}, '4': {0, 33}, '5': {0, 34},
'6': {0, 35}, '7': {0, 36}, '8': {0, 37}, '9': {0, 38}, '0': {0, 39},
59: {0, 51}, // ;
39: {0, 52}, // '
96: {0, 53}, // `
44: {0, 54}, // ,
46: {0, 55}, // .
47: {0, 56}, // /
// Shifted numbers / Symbols
'!': {2, 30}, // Shift + 1
'@': {2, 31}, // Shift + 2
'#': {2, 32}, // Shift + 3
'$': {2, 33}, // Shift + 4
'%': {2, 34}, // Shift + 5
'^': {2, 35}, // Shift + 6
'&': {2, 36}, // Shift + 7
'*': {2, 37}, // Shift + 8
'(': {2, 38}, // Shift + 9
')': {2, 39}, // Shift + 0
65: {2, 4}, // A
66: {2, 5}, // B
67: {2, 6}, // C
68: {2, 7}, // D
69: {2, 8}, // E
70: {2, 9}, // F
71: {2, 10}, // G
72: {2, 11}, // H
73: {2, 12}, // I
74: {2, 13}, // J
75: {2, 14}, // K
76: {2, 15}, // L
77: {2, 16}, // M
78: {2, 17}, // N
79: {2, 18}, // O
80: {2, 19}, // P
81: {2, 20}, // Q
82: {2, 21}, // R
83: {2, 22}, // S
84: {2, 23}, // T
85: {2, 24}, // U
86: {2, 25}, // V
87: {2, 26}, // W
88: {2, 27}, // X
89: {2, 28}, // Y
90: {2, 29}, // Z
// Other common characters
'\n': {0, 40}, // Enter (Return)
'\t': {0, 43}, // Tab
' ': {0, 44}, // Space
'-': {0, 45}, // Hyphen / Minus
'=': {0, 46}, // Equals
'[': {0, 47}, // Left Square Bracket
']': {0, 48}, // Right Square Bracket
'\\': {0, 49}, // Backslash
33: {2, 30}, // !
64: {2, 31}, // @
35: {2, 32}, // #
36: {2, 33}, // $
37: {2, 34}, // %
94: {2, 35}, // ^
38: {2, 36}, // &
42: {2, 37}, // *
40: {2, 38}, // (
41: {2, 39}, // )
';': {0, 51}, // Semicolon
'\'': {0, 52}, // Apostrophe / Single Quote
'`': {0, 53}, // Grave Accent / Backtick
',': {0, 54}, // Comma
'.': {0, 55}, // Period / Dot
'/': {0, 56}, // Slash
95: {2, 45}, // _
43: {2, 46}, // +
123: {2, 47}, // {
125: {2, 48}, // }
124: {2, 49}, // |
// Shifted symbols
'_': {2, 45}, // Underscore (Shift + Hyphen)
'+': {2, 46}, // Plus (Shift + Equals)
'{': {2, 47}, // Left Curly Brace (Shift + Left Square Bracket)
'}': {2, 48}, // Right Curly Brace (Shift + Right Square Bracket)
'|': {2, 49}, // Pipe (Shift + Backslash)
58: {2, 51}, // :
34: {2, 52}, // "
126: {2, 53}, // ~
60: {2, 54}, // <
62: {2, 55}, // >
63: {2, 56}, // ?
':': {2, 51}, // Colon (Shift + Semicolon)
'"': {2, 52}, // Double Quote (Shift + Apostrophe)
'~': {2, 53}, // Tilde (Shift + Grave Accent)
'<': {2, 54}, // Less Than (Shift + Comma)
'>': {2, 55}, // Greater Than (Shift + Period)
'?': {2, 56}, // Question Mark (Shift + Slash)
}

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,14 +1,17 @@
package vm
import (
"time"
"NanoKVM-Server/common"
"NanoKVM-Server/proto"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
var hdmiEnabled = true
func (s *Service) ResetHdmi(c *gin.Context) {
var rsp proto.Response
@@ -17,7 +20,42 @@ func (s *Service) ResetHdmi(c *gin.Context) {
vision.SetHDMI(false)
time.Sleep(1 * time.Second)
vision.SetHDMI(true)
hdmiEnabled = true
rsp.OkRsp(c)
log.Debug("reset hdmi")
}
func (s *Service) EnableHdmi(c *gin.Context) {
var rsp proto.Response
vision := common.GetKvmVision()
vision.SetHDMI(true)
hdmiEnabled = true
rsp.OkRsp(c)
log.Debug("enable hdmi")
}
func (s *Service) DisableHdmi(c *gin.Context) {
var rsp proto.Response
vision := common.GetKvmVision()
vision.SetHDMI(false)
hdmiEnabled = false
rsp.OkRsp(c)
log.Debug("disable hdmi")
}
func (s *Service) GetHdmiState(c *gin.Context) {
var rsp proto.Response
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

@@ -62,6 +62,29 @@ export function resetHdmi() {
return http.post('/api/vm/hdmi/reset');
}
// get HDMI state
export function getHdmiState() {
return http.get('/api/vm/hdmi');
}
// enable HDMI
export function enableHdmi() {
return http.post('/api/vm/hdmi/enable');
}
// disable HDMI
export function disableHdmi() {
return http.post('/api/vm/hdmi/disable');
}
// set HDMI state
export function setHdmiState(enabled: boolean) {
if (enabled) {
return enableHdmi();
}
return disableHdmi();
}
// get SSH state
export function getSSHState() {
return http.get('/api/vm/ssh');

View File

@@ -103,3 +103,7 @@
.keyboard-header {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
.hg-candidate-box {
display: none !important;
}

View File

@@ -87,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: {
@@ -258,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

@@ -40,9 +40,9 @@ const ko = {
},
wifi: {
title: 'Wi-Fi',
description: 'Wi-Fi 설정 for NanoKVM',
description: 'NanoKVM Wi-Fi 설정',
success: 'NanoKVM의 네트워크 상태를 확인하고 새 IP 주소로 접속하세요.',
failed: '작업이 실패했습니다. 다시 시도하세요.',
failed: '작업에 실패했습니다. 다시 시도하세요.',
confirmBtn: '확인',
finishBtn: '완료'
},

View File

@@ -14,8 +14,7 @@ const ru = {
placeholderPassword2: 'Введите пароль снова',
noEmptyUsername: 'Имя пользователя не может быть пустым',
noEmptyPassword: 'Пароль не может быть пустым',
noAccount:
'Не удалось получить информацию о пользователе, пожалуйста, обновите веб-страницу или сбросьте пароль',
noAccount: 'Не удалось получить информацию о пользователе, пожалуйста, обновите веб-страницу или сбросьте пароль',
invalidUser: 'Неверное имя пользователя или пароль',
error: 'Непредвиденная ошибка',
changePassword: 'Изменить пароль',
@@ -50,6 +49,7 @@ const ru = {
screen: {
title: 'Экран',
video: 'Видеорежим',
videoDirectTips: 'Включите HTTPS в "Настройки > Устройство", чтобы использовать этот режим',
resolution: 'Разрешение',
auto: 'Автоматическое',
autoTips:
@@ -236,6 +236,10 @@ const ru = {
description: 'Включить удаленный доступ по SSH',
tip: 'Задайте сильный пароль перед включением (Аккаунт - Пароль - Обновить)'
},
tls: {
description: 'Включить HTTPS Protocol',
tip: 'Имейте в виду: использование HTTPS может увеличить задержку, особенно в режиме видео MJPEG.'
},
advanced: 'Дополнительные настройки',
swap: {
title: 'Файл подкачки',
@@ -286,7 +290,8 @@ const ru = {
upTailscale: 'Переместите tailscale в каталог /usr/bin/ на NanoKVM',
upTailscaled: 'Переместите tailscaled в каталог /usr/sbin/ на NanoKVM',
refresh: 'Обновите текущую страницу',
notLogin: 'Устройство не привязано. Войдите, чтобы привязать его к аккаунту.',
notLogin:
'Устройство не привязано. Войдите, чтобы привязать его к аккаунту.',
urlPeriod: 'Этот адрес действителен в течение 10 минут',
login: 'Войти',
loginSuccess: 'Вход выполнен',
@@ -295,7 +300,7 @@ const ru = {
deviceIP: 'IP адрес устройства',
account: 'Аккаунт',
logout: 'Выход',
logout2: 'Вы действительно хотите выйти?',
logoutDesc: 'Вы действительно хотите выйти?',
uninstall: 'Удалить Tailscale',
okBtn: 'Да',
cancelBtn: 'Нет'

View File

@@ -14,13 +14,11 @@ const uk = {
placeholderPassword2: 'Введіть пароль ще раз',
noEmptyUsername: "Ім'я користувача не може бути порожнім",
noEmptyPassword: 'Пароль не може бути порожнім',
noAccount:
'Не вдалося отримати інформацію про користувача, оновіть веб-сторінку або скиньте пароль',
noAccount: 'Не вдалося отримати інформацію про користувача, оновіть веб-сторінку або скиньте пароль',
invalidUser: "Недійсне ім'я користувача або пароль",
error: 'Якась халепа! Непередбачена помилка :(',
changePassword: 'Змінити пароль',
changePasswordDesc:
'Для безпеки вашого пристрою, будь ласка, змініть пароль для входу в веб-інтерфейс.',
changePasswordDesc: 'Для безпеки вашого пристрою, будь ласка, змініть пароль для входу в веб-інтерфейс.',
differentPassword: 'Паролі не збігаються',
illegalUsername: "Ім'я користувача містить недопустимі символи",
illegalPassword: 'Пароль містить недопустимі символи',
@@ -51,6 +49,7 @@ const uk = {
screen: {
title: 'Екран',
video: 'Відеорежим',
videoDirectTips: 'Увімкніть HTTPS у "Налаштування > Пристрій", щоб використовувати цей режим',
resolution: 'Роздільна здатність',
auto: 'Автоматично',
autoTips:
@@ -88,27 +87,27 @@ const uk = {
mode: 'Режим миші',
absolute: 'Абсолютний режим',
relative: 'Відносний режим',
requestPointer:
'Використовується відносний режим. Будь ласка, натисніть на робочий стіл, щоб отримати курсор миші.',
requestPointer: 'Використовується відносний режим. Будь ласка, натисніть на робочий стіл, щоб отримати курсор миші.',
resetHid: 'Скинути HID',
hidOnly: {
title: 'Режим лише HID',
desc: 'Якщо ваша миша та клавіатура перестають відповідати та скинути HID, це не допомагає, це може бути проблемою сумісності між NanoKVM та пристроєм. Спробуйте ввімкнути режим "лише HID" лише для кращої сумісності.',
tip1: 'Увімкнення режиму "лише HID" демонтує віртуальний U-диск і віртуальну мережу',
tip2: 'У режимі "лише HID" монтування іміджей буде вимкнено',
tip2: 'У режимі "лише HID" монтування образів буде вимкнено',
tip3: 'NANOKVM автоматично перезавантажиться після перемикання режимів',
enable: 'Увімкнути режим "лише HID"',
disable: 'Ввимкнути режим "лише HID"'
}
},
image: {
title: 'Іміджи',
title: 'Образи',
loading: 'Завантаження...',
empty: 'Нічого не знайдено',
mountFailed: 'Не вдалося змонтувати імідж у режимі CD-ROM',
cdrom: 'Встановіть образ в режимі CD-ROM',
mountFailed: 'Не вдалося змонтувати образ у режимі CD-ROM',
mountDesc:
'У деяких системах необхідно витягнути віртуальний диск на віддаленому хості перед монтуванням файлу образа.',
refresh: 'Оновіть список іміджей',
refresh: 'Оновіть список образів',
tips: {
title: 'Як завантажити',
usb1: "Під'єднайте NanoKVM до вашого комп'ютера через USB.",
@@ -165,15 +164,15 @@ const uk = {
ok: 'Ок'
},
download: {
title: 'Завантажувач іміджей',
input: 'Введіть URL-адресу віддаленого іміджа',
title: 'Завантажувач образів',
input: 'Введіть URL-адресу віддаленого образу',
ok: 'Так',
disabled: 'розділ даних /data у режіме RO, тому ми не можемо завантажити імідж'
disabled: 'Розділ даних /data у режимі лише для читання, тому ми не можемо завантажити образ'
},
power: {
title: 'Живлення',
showConfirm: 'Confirmation',
showConfirmTip: 'Power operations require an extra confirmation',
showConfirm: 'Підтвердження',
showConfirmTip: 'Енергетичні операції потребують додаткового підтвердження',
reset: 'Скидання',
power: 'Живлення',
powerShort: 'Живлення (коротке натискання)',
@@ -191,19 +190,24 @@ const uk = {
ip: 'IP',
mdns: 'mDNS',
application: 'Версія додатка',
applicationTip: 'Версія веб-додатка NANOKVM',
applicationTip: 'Версія веб-додатка NanoKVM',
image: 'Версія образу',
imageTip: 'Версія системного іміджу Nanokvm',
imageTip: 'Версія системного образу NanoKVM',
deviceKey: 'Ключ пристрою',
community: 'Спільнота',
hostname: "Ім'я вузла",
hostnameUpdated: "Ім'я вузла оновлено. Перезавантажте систему, щоб застосувати зміни."
hostnameUpdated: "Ім'я вузла оновлено. Перезавантажте систему, щоб застосувати зміни.",
ipType: {
Wired: 'Дротове',
Wireless: 'Бездротове',
Other: 'Інший'
}
},
appearance: {
title: 'Зовнішній вигляд',
display: 'Відображення',
language: 'Мова',
menuBar: 'Menu Bar',
menuBar: 'Панель меню',
menuBarDesc: 'Піктограми відображення в панелі меню',
webTitle: 'Веб-назва',
webTitleDesc: 'Налаштуйте назву веб-сторінки'
@@ -232,11 +236,16 @@ const uk = {
description: 'Увімкніть віддалений доступ по SSH',
tip: 'Перед ввімкненням встановіть надійний пароль (Обліковий запис - Зміна пароля)'
},
tls: {
description: 'Увімкнути протокол HTTPS',
tip: 'Будьте в курсі: Використання HTTPS може збільшити затримку, особливо в режимі відео MJPEG.'
},
advanced: 'Розширені налаштування',
swap: {
title: 'Swap (файл підкачки)',
disable: 'Відключено',
description: 'Встановіть розмір файлу свопу',
tip: "Увімкнення цієї функції може скоротити корисне життя вашої SD-карти!"
tip: "Увімкнення цієї опції може вивести з ладу SD карту!"
},
mouseJiggler: {
title: 'Налаштування пацюка',

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

@@ -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,12 +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() {
return localStorage.getItem(KEYBOARD_LAYOUT_KEY);
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

@@ -84,6 +84,7 @@ export const KeyboardCodes: Map<string, number> = new Map([
['BracketRight', 48],
['Backslash', 49],
['IntlBackslash', 49],
['Backquote_azerty', 100],
['Semicolon', 51],
['Quote', 52],

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { AppleOutlined, WindowsOutlined } from '@ant-design/icons';
import { ConfigProvider, Segmented, theme } from 'antd';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { XIcon } from 'lucide-react';
@@ -10,9 +9,10 @@ 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, setKeyboardLayout, getLanguage } from '@/lib/localstorage.ts';
import * as storage from '@/lib/localstorage.ts';
import { client } from '@/lib/websocket.ts';
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
@@ -31,18 +31,56 @@ export const VirtualKeyboard = () => {
const [isKeyboardOpen, setIsKeyboardOpen] = useAtom(isKeyboardOpenAtom);
const [layout, setLayout] = useState('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);
const systems = [
{ value: 'win', icon: <WindowsOutlined /> },
{ value: 'mac', icon: <AppleOutlined /> }
];
const languages = [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'French' },
{ value: 'ru', label: 'Russian' }
];
useEffect(() => {
const keyboardLayout = getKeyboardLayout();
if (keyboardLayout && ['default', 'mac', 'rus'].includes(keyboardLayout)) {
setLayout(keyboardLayout);
const system = storage.getKeyboardSystem();
if (system && ['win', 'mac'].includes(system)) {
setKeyboardSystem(system);
}
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)) {
if (activeModifierKeys.includes(key)) {
@@ -66,8 +104,7 @@ export const VirtualKeyboard = () => {
}
function sendKeydown(key: string) {
const specialKey = specialKeyMap.get(key);
const code = KeyboardCodes.get(specialKey ? specialKey : key);
const code = getKeyCode(key);
if (!code) {
console.log('unknown code: ', key);
return;
@@ -78,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]);
@@ -121,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(' ') }];
@@ -132,11 +199,6 @@ export const VirtualKeyboard = () => {
return theme;
}
function selectLayout(value: string) {
setKeyboardLayout(value);
setLayout(value);
}
return (
<Drawer.Root open={isKeyboardOpen} onOpenChange={setIsKeyboardOpen} modal={false}>
<Drawer.Portal>
@@ -148,23 +210,30 @@ export const VirtualKeyboard = () => {
>
{/* header */}
<div className="flex items-center justify-between px-3 py-1">
<div className="flex flex-row">
<ConfigProvider
theme={{
algorithm: theme.defaultAlgorithm
}}
>
<Segmented
options={[
{ label: 'Win', value: 'default', icon: <WindowsOutlined /> },
{ label: 'Mac', value: 'mac', icon: <AppleOutlined /> },
...(getLanguage() === 'ru' ? [{ label: 'Rus', value: 'rus', icon: <WindowsOutlined /> }] : [])
]}
value={layout}
onChange={selectLayout}
<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}
/>
</ConfigProvider>
</div>
{keyboardLanguage === 'en' && (
<Segmented
size="small"
options={systems}
value={keyboardSystem}
onChange={selectSystem}
/>
)}
</div>
</ConfigProvider>
<div className="flex w-[100px] items-center justify-end">
<div
@@ -185,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',
@@ -26,6 +28,14 @@ export const keyboardOptions = {
'{capslock} RusA RusS RusD RusF RusG RusH RusJ RusK RusL RusSemicolon RusQuote {enter}',
'{shiftleft} RusZ RusX RusC RusV RusB RusN RusM RusComma RusPeriod RusSlash {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
],
azerty: [
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'Backquote_azerty Digit1_azerty Digit2_azerty Digit3_azerty Digit4_azerty Digit5_azerty Digit6_azerty Digit7_azerty Digit8_azerty Digit9_azerty Digit0_azerty Minus_azerty Equal_azerty {backspace}',
'{tab} KeyA_azerty KeyZ_azerty KeyE_azerty KeyR_azerty KeyT_azerty KeyY_azerty KeyU_azerty KeyI_azerty KeyO_azerty KeyP_azerty BracketLeft_azerty BracketRight_azerty Backslash_azerty',
'{capslock} KeyQ_azerty KeyS_azerty KeyD_azerty KeyF_azerty KeyG_azerty KeyH_azerty KeyJ_azerty KeyK_azerty KeyL_azerty Semicolon_azerty Quote_azerty {enter}',
'{shiftleft} KeyW_azerty KeyX_azerty KeyC_azerty KeyV_azerty KeyB_azerty KeyN_azerty KeyM_azerty Comma_azerty Period_azerty Slash_azerty {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
]
},
display: {
@@ -111,7 +121,7 @@ export const keyboardOptions = {
RusBracketLeft: 'Х',
RusBracketRight: 'Ъ',
RusBackslash: '/<br>\\',
RusA: 'Ф',
RusS: 'Ы',
RusD: 'В',
@@ -134,7 +144,71 @@ export const keyboardOptions = {
RusComma: 'Б',
RusPeriod: 'Ю',
RusSlash: ',<br/>.',
// 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/>}',
// 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/>*',
// 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/>ù',
// 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/>!'
},
// Enable layout-specific display
mergeDisplay: true,
layoutCandidates: {
default: 'default',
shift: 'shift',
azerty: 'azerty'
}
// ...remaining options...
};
// control keys

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

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

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