mirror of https://github.com/jetkvm/kvm.git
feat: implement pointer-lock and keyboard-lock (#352)
* feat: implement pointer-lock and keyboard-lock * feat: Add Pointer lock functionality and SSL support in dev mode - Introduced @vitejs/plugin-basic-ssl for enabling SSL in development. - Added a new script `dev:ssl` to run the development server with SSL. - Implemented pointer lock feature in the WebRTCVideo component, enhancing user interaction. - Added a PointerLockBar component to guide users on enabling mouse control. - Cleaned up the VideoOverlay and WebRTCVideo components for better readability and functionality. --------- Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
This commit is contained in:
parent
440f85f091
commit
2b2a14204d
|
@ -11,6 +11,7 @@
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@headlessui/tailwindcss": "^0.2.1",
|
"@headlessui/tailwindcss": "^0.2.1",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-clipboard": "^0.1.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-unicode11": "^0.8.0",
|
"@xterm/addon-unicode11": "^0.8.0",
|
||||||
|
@ -105,7 +106,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
|
@ -121,7 +121,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
@ -137,7 +136,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
@ -153,7 +151,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
@ -169,7 +166,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
@ -185,7 +181,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
@ -201,7 +196,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
|
@ -217,7 +211,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
|
@ -233,7 +226,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -249,7 +241,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -265,7 +256,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -281,7 +271,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -297,7 +286,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -313,7 +301,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -329,7 +316,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -345,7 +331,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -361,7 +346,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -377,7 +361,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
|
@ -393,7 +376,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
|
@ -409,7 +391,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
|
@ -425,7 +406,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
@ -441,7 +421,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
@ -457,7 +436,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
@ -890,7 +868,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
@ -903,7 +880,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
@ -916,7 +892,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
@ -929,7 +904,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
@ -942,7 +916,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -955,7 +928,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -968,7 +940,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -981,7 +952,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64le"
|
"ppc64le"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -994,7 +964,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -1007,7 +976,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -1020,7 +988,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -1033,7 +1000,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
@ -1046,7 +1012,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
@ -1059,7 +1024,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
@ -1072,7 +1036,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
@ -1426,8 +1389,7 @@
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
|
||||||
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
|
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/json5": {
|
"node_modules/@types/json5": {
|
||||||
"version": "0.0.29",
|
"version": "0.0.29",
|
||||||
|
@ -1675,6 +1637,17 @@
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
|
||||||
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="
|
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitejs/plugin-basic-ssl": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react-swc": {
|
"node_modules/@vitejs/plugin-react-swc": {
|
||||||
"version": "3.8.0",
|
"version": "3.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.8.0.tgz",
|
||||||
|
@ -2740,7 +2713,6 @@
|
||||||
"version": "0.20.2",
|
"version": "0.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
|
||||||
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
|
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
|
@ -5345,7 +5317,6 @@
|
||||||
"version": "4.14.1",
|
"version": "4.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.1.tgz",
|
||||||
"integrity": "sha512-4LnHSdd3QK2pa1J6dFbfm1HN0D7vSK/ZuZTsdyUAlA6Rr1yTouUTL13HaDOGJVgby461AhrNGBS7sCGXXtT+SA==",
|
"integrity": "sha512-4LnHSdd3QK2pa1J6dFbfm1HN0D7vSK/ZuZTsdyUAlA6Rr1yTouUTL13HaDOGJVgby461AhrNGBS7sCGXXtT+SA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.5"
|
"@types/estree": "1.0.5"
|
||||||
},
|
},
|
||||||
|
@ -6250,7 +6221,6 @@
|
||||||
"version": "5.2.8",
|
"version": "5.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.8.tgz",
|
||||||
"integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==",
|
"integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.20.1",
|
"esbuild": "^0.20.1",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "./dev_device.sh",
|
"dev": "./dev_device.sh",
|
||||||
|
"dev:ssl": "USE_SSL=true ./dev_device.sh",
|
||||||
"dev:cloud": "vite dev --mode=cloud-development",
|
"dev:cloud": "vite dev --mode=cloud-development",
|
||||||
"build": "npm run build:prod",
|
"build": "npm run build:prod",
|
||||||
"build:device": "tsc && vite build --mode=device --emptyOutDir",
|
"build:device": "tsc && vite build --mode=device --emptyOutDir",
|
||||||
|
@ -21,6 +22,7 @@
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@headlessui/tailwindcss": "^0.2.1",
|
"@headlessui/tailwindcss": "^0.2.1",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-clipboard": "^0.1.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-unicode11": "^0.8.0",
|
"@xterm/addon-unicode11": "^0.8.0",
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { LuPlay } from "react-icons/lu";
|
||||||
import { Button, LinkButton } from "@components/Button";
|
import { Button, LinkButton } from "@components/Button";
|
||||||
import LoadingSpinner from "@components/LoadingSpinner";
|
import LoadingSpinner from "@components/LoadingSpinner";
|
||||||
import Card, { GridCard } from "@components/Card";
|
import Card, { GridCard } from "@components/Card";
|
||||||
|
import { BsMouseFill } from "react-icons/bs";
|
||||||
|
|
||||||
interface OverlayContentProps {
|
interface OverlayContentProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
@ -358,3 +359,36 @@ export function NoAutoplayPermissionsOverlay({
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PointerLockBarProps {
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PointerLockBar({ show }: PointerLockBarProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{show ? (
|
||||||
|
<motion.div
|
||||||
|
className="absolute -top-[36px] left-0 right-0 z-20 bg-white"
|
||||||
|
initial={{ y: 20, opacity: 0, zIndex: 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ y: 43, zIndex: 0 }}
|
||||||
|
transition={{ duration: 0.5, ease: "easeInOut", delay: 0.5 }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Card className="rounded-b-none shadow-none !outline-0">
|
||||||
|
<div className="flex items-center justify-between border border-slate-800/50 px-4 py-2 outline-0 backdrop-blur-sm dark:border-slate-300/20 dark:bg-slate-800">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<BsMouseFill className="h-4 w-4 text-blue-700 dark:text-blue-500" />
|
||||||
|
<span className="text-sm text-black dark:text-white">
|
||||||
|
Click on the video to enable mouse control
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
useMouseStore,
|
useMouseStore,
|
||||||
useRTCStore,
|
useRTCStore,
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
|
useUiStore,
|
||||||
useVideoStore,
|
useVideoStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
|
@ -17,11 +18,13 @@ import MacroBar from "@/components/MacroBar";
|
||||||
import InfoBar from "@components/InfoBar";
|
import InfoBar from "@components/InfoBar";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HDMIErrorOverlay,
|
HDMIErrorOverlay,
|
||||||
LoadingVideoOverlay,
|
LoadingVideoOverlay,
|
||||||
NoAutoplayPermissionsOverlay,
|
NoAutoplayPermissionsOverlay,
|
||||||
|
PointerLockBar,
|
||||||
} from "./VideoOverlay";
|
} from "./VideoOverlay";
|
||||||
|
|
||||||
export default function WebRTCVideo() {
|
export default function WebRTCVideo() {
|
||||||
|
@ -30,7 +33,7 @@ export default function WebRTCVideo() {
|
||||||
const mediaStream = useRTCStore(state => state.mediaStream);
|
const mediaStream = useRTCStore(state => state.mediaStream);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||||
|
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
|
||||||
// Store hooks
|
// Store hooks
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
||||||
|
@ -53,14 +56,13 @@ export default function WebRTCVideo() {
|
||||||
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
|
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
|
||||||
const isVideoLoading = !isPlaying;
|
const isVideoLoading = !isPlaying;
|
||||||
|
|
||||||
// console.log("peerConnection?.connectionState", peerConnection?.connectionState);
|
|
||||||
|
|
||||||
// Keyboard related states
|
// Keyboard related states
|
||||||
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
|
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
|
||||||
useHidStore();
|
useHidStore();
|
||||||
|
|
||||||
// Misc states and hooks
|
// Misc states and hooks
|
||||||
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
|
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
|
||||||
|
const disableVideoFocusTrap = useUiStore(state => state.disableVideoFocusTrap);
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
|
|
||||||
// Video-related
|
// Video-related
|
||||||
|
@ -97,6 +99,64 @@ export default function WebRTCVideo() {
|
||||||
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
|
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Pointer lock and keyboard lock related
|
||||||
|
const isPointerLockPossible = window.location.protocol === "https:";
|
||||||
|
|
||||||
|
const checkNavigatorPermissions = useCallback(async (permissionName: string) => {
|
||||||
|
const name = permissionName as PermissionName;
|
||||||
|
const { state } = await navigator.permissions.query({ name });
|
||||||
|
return state === "granted";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestPointerLock = useCallback(async () => {
|
||||||
|
if (document.pointerLockElement) return;
|
||||||
|
|
||||||
|
const isPointerLockGranted = await checkNavigatorPermissions("pointer-lock");
|
||||||
|
if (isPointerLockGranted && settings.mouseMode === "relative") {
|
||||||
|
videoElm.current?.requestPointerLock();
|
||||||
|
}
|
||||||
|
}, [checkNavigatorPermissions, settings.mouseMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPointerLockPossible || !videoElm.current) return;
|
||||||
|
|
||||||
|
const handlePointerLockChange = () => {
|
||||||
|
if (document.pointerLockElement) {
|
||||||
|
notifications.success("Pointer lock Enabled, hold escape to exit");
|
||||||
|
setIsPointerLockActive(true);
|
||||||
|
} else {
|
||||||
|
notifications.success("Pointer lock disabled");
|
||||||
|
setIsPointerLockActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const signal = abortController.signal;
|
||||||
|
|
||||||
|
document.addEventListener("pointerlockchange", handlePointerLockChange, { signal });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}, [isPointerLockPossible, videoElm]);
|
||||||
|
|
||||||
|
const requestFullscreen = useCallback(async () => {
|
||||||
|
videoElm.current?.requestFullscreen({
|
||||||
|
navigationUI: "show",
|
||||||
|
});
|
||||||
|
|
||||||
|
// we do not care about pointer lock if it's for fullscreen
|
||||||
|
await requestPointerLock();
|
||||||
|
|
||||||
|
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
|
||||||
|
if (isKeyboardLockGranted) {
|
||||||
|
if ("keyboard" in navigator) {
|
||||||
|
// @ts-ignore
|
||||||
|
await navigator.keyboard.lock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [requestPointerLock, checkNavigatorPermissions]);
|
||||||
|
|
||||||
// Mouse-related
|
// Mouse-related
|
||||||
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
|
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
|
||||||
const sendRelMouseMovement = useCallback(
|
const sendRelMouseMovement = useCallback(
|
||||||
|
@ -113,12 +173,18 @@ export default function WebRTCVideo() {
|
||||||
const relMouseMoveHandler = useCallback(
|
const relMouseMoveHandler = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (settings.mouseMode !== "relative") return;
|
if (settings.mouseMode !== "relative") return;
|
||||||
|
if (isPointerLockActive === false && isPointerLockPossible === true) return;
|
||||||
|
|
||||||
// Send mouse movement
|
// Send mouse movement
|
||||||
const { buttons } = e;
|
const { buttons } = e;
|
||||||
sendRelMouseMovement(e.movementX, e.movementY, buttons);
|
sendRelMouseMovement(e.movementX, e.movementY, buttons);
|
||||||
},
|
},
|
||||||
[sendRelMouseMovement, settings.mouseMode],
|
[
|
||||||
|
isPointerLockActive,
|
||||||
|
isPointerLockPossible,
|
||||||
|
sendRelMouseMovement,
|
||||||
|
settings.mouseMode,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendAbsMouseMovement = useCallback(
|
const sendAbsMouseMovement = useCallback(
|
||||||
|
@ -294,7 +360,8 @@ export default function WebRTCVideo() {
|
||||||
// console.log("KEYUP: Not focusing on the video", document.activeElement);
|
// console.log("KEYUP: Not focusing on the video", document.activeElement);
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
console.log(document.activeElement);
|
|
||||||
|
// console.log(document.activeElement);
|
||||||
|
|
||||||
setIsNumLockActive(e.getModifierState("NumLock"));
|
setIsNumLockActive(e.getModifierState("NumLock"));
|
||||||
setIsCapsLockActive(e.getModifierState("CapsLock"));
|
setIsCapsLockActive(e.getModifierState("CapsLock"));
|
||||||
|
@ -512,36 +579,51 @@ export default function WebRTCVideo() {
|
||||||
|
|
||||||
// Setup Relative Mouse Events
|
// Setup Relative Mouse Events
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
function setupRelativeMouseEventListeners() {
|
function setupRelativeMouseEventListeners() {
|
||||||
if (settings.mouseMode !== "relative") return;
|
if (settings.mouseMode !== "relative") return;
|
||||||
|
// Relative mouse mode should only be active if the pointer lock is active and Pointer Lock is possible
|
||||||
|
|
||||||
|
const videoElmRefValue = videoElm.current;
|
||||||
|
if (!videoElmRefValue) return;
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const signal = abortController.signal;
|
const signal = abortController.signal;
|
||||||
|
|
||||||
// We bind to the larger container in relative mode because of delta between the acceleration of the local
|
videoElmRefValue.addEventListener("mousemove", relMouseMoveHandler, { signal });
|
||||||
// mouse and the mouse movement of the remote mouse. This simply makes it a bit less painful to use.
|
videoElmRefValue.addEventListener("pointerdown", relMouseMoveHandler, { signal });
|
||||||
// When we get Pointer Lock support, we can remove this.
|
videoElmRefValue.addEventListener("pointerup", relMouseMoveHandler, { signal });
|
||||||
const containerElm = containerRef.current;
|
videoElmRefValue.addEventListener(
|
||||||
if (!containerElm) return;
|
"click",
|
||||||
|
() => {
|
||||||
containerElm.addEventListener("mousemove", relMouseMoveHandler, { signal });
|
if (isPointerLockPossible && !document.pointerLockElement) {
|
||||||
containerElm.addEventListener("pointerdown", relMouseMoveHandler, { signal });
|
requestPointerLock();
|
||||||
containerElm.addEventListener("pointerup", relMouseMoveHandler, { signal });
|
}
|
||||||
|
},
|
||||||
containerElm.addEventListener("wheel", mouseWheelHandler, {
|
{ signal },
|
||||||
|
);
|
||||||
|
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
|
||||||
signal,
|
signal,
|
||||||
passive: true,
|
passive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
|
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
|
||||||
containerElm.addEventListener("contextmenu", preventContextMenu, { signal });
|
videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { signal });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[settings.mouseMode, relMouseMoveHandler, mouseWheelHandler],
|
[
|
||||||
|
settings.mouseMode,
|
||||||
|
relMouseMoveHandler,
|
||||||
|
mouseWheelHandler,
|
||||||
|
disableVideoFocusTrap,
|
||||||
|
requestPointerLock,
|
||||||
|
isPointerLockPossible,
|
||||||
|
isPointerLockActive,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasNoAutoPlayPermissions = useMemo(() => {
|
const hasNoAutoPlayPermissions = useMemo(() => {
|
||||||
|
@ -552,33 +634,43 @@ export default function WebRTCVideo() {
|
||||||
return true;
|
return true;
|
||||||
}, [peerConnection?.connectionState, isPlaying, hdmiError, videoHeight, videoWidth]);
|
}, [peerConnection?.connectionState, isPlaying, hdmiError, videoHeight, videoWidth]);
|
||||||
|
|
||||||
|
const showPointerLockBar = useMemo(() => {
|
||||||
|
if (settings.mouseMode !== "relative") return false;
|
||||||
|
if (!isPointerLockPossible) return false;
|
||||||
|
if (isPointerLockActive) return false;
|
||||||
|
if (isVideoLoading) return false;
|
||||||
|
if (!isPlaying) return false;
|
||||||
|
if (videoHeight === 0 || videoWidth === 0) return false;
|
||||||
|
return true;
|
||||||
|
}, [
|
||||||
|
settings.mouseMode,
|
||||||
|
isPointerLockPossible,
|
||||||
|
isPointerLockActive,
|
||||||
|
isVideoLoading,
|
||||||
|
isPlaying,
|
||||||
|
videoHeight,
|
||||||
|
videoWidth,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full w-full grid-rows-layout">
|
<div className="grid h-full w-full grid-rows-layout">
|
||||||
<div className="min-h-[39.5px] flex flex-col">
|
<div className="flex min-h-[39.5px] flex-col">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<fieldset disabled={peerConnection?.connectionState !== "connected"} className="contents">
|
<fieldset
|
||||||
<Actionbar
|
disabled={peerConnection?.connectionState !== "connected"}
|
||||||
requestFullscreen={async () =>
|
className="contents"
|
||||||
videoElm.current?.requestFullscreen({
|
>
|
||||||
navigationUI: "show",
|
<Actionbar requestFullscreen={requestFullscreen} />
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MacroBar />
|
<MacroBar />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div ref={containerRef} className="h-full overflow-hidden">
|
||||||
ref={containerRef}
|
|
||||||
className={cx("h-full overflow-hidden", {
|
|
||||||
"cursor-none": settings.mouseMode === "relative" && settings.isCursorHidden,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"absolute inset-0 bg-blue-50/40 opacity-80 dark:bg-slate-800/40",
|
"absolute inset-0 -z-0 bg-blue-50/40 opacity-80 dark:bg-slate-800/40",
|
||||||
"[background-image:radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px),radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px)] dark:[background-image:radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px),radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px)]",
|
"[background-image:radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px),radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px)] dark:[background-image:radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px),radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px)]",
|
||||||
"[background-position:0_0,10px_10px]",
|
"[background-position:0_0,10px_10px]",
|
||||||
"[background-size:20px_20px]",
|
"[background-size:20px_20px]",
|
||||||
|
@ -590,48 +682,51 @@ export default function WebRTCVideo() {
|
||||||
<div className="grid flex-grow grid-rows-bodyFooter overflow-hidden">
|
<div className="grid flex-grow grid-rows-bodyFooter overflow-hidden">
|
||||||
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
|
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
|
||||||
<div className="relative flex h-full w-full items-center justify-center">
|
<div className="relative flex h-full w-full items-center justify-center">
|
||||||
<video
|
<div className="relative inline-block">
|
||||||
ref={videoElm}
|
{/* In relative mouse mode and under https, we enable the pointer lock, and to do so we need a bar to show the user to click on the video to enable mouse control */}
|
||||||
autoPlay={true}
|
<PointerLockBar show={showPointerLockBar} />
|
||||||
controls={false}
|
<video
|
||||||
onPlaying={onVideoPlaying}
|
ref={videoElm}
|
||||||
onPlay={onVideoPlaying}
|
autoPlay={true}
|
||||||
muted={true}
|
controls={false}
|
||||||
playsInline
|
onPlaying={onVideoPlaying}
|
||||||
disablePictureInPicture
|
onPlay={onVideoPlaying}
|
||||||
controlsList="nofullscreen"
|
muted={true}
|
||||||
className={cx(
|
playsInline
|
||||||
"outline-50 max-h-full max-w-full object-contain transition-all duration-1000",
|
disablePictureInPicture
|
||||||
{
|
controlsList="nofullscreen"
|
||||||
"cursor-none":
|
className={cx(
|
||||||
settings.mouseMode === "absolute" &&
|
"z-30 max-h-full min-h-[384px] min-w-[512px] max-w-full bg-black/50 object-contain transition-all duration-1000",
|
||||||
settings.isCursorHidden,
|
{
|
||||||
"opacity-0":
|
"cursor-none": settings.isCursorHidden,
|
||||||
isVideoLoading ||
|
"opacity-0":
|
||||||
hdmiError ||
|
isVideoLoading ||
|
||||||
peerConnectionState !== "connected",
|
hdmiError ||
|
||||||
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
|
peerConnectionState !== "connected",
|
||||||
isPlaying,
|
"!opacity-60": showPointerLockBar,
|
||||||
},
|
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
|
||||||
)}
|
isPlaying,
|
||||||
/>
|
},
|
||||||
{peerConnection?.connectionState == "connected" && (
|
)}
|
||||||
<div
|
/>
|
||||||
style={{ animationDuration: "500ms" }}
|
{peerConnection?.connectionState == "connected" && (
|
||||||
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0"
|
<div
|
||||||
>
|
style={{ animationDuration: "500ms" }}
|
||||||
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0"
|
||||||
<LoadingVideoOverlay show={isVideoLoading} />
|
>
|
||||||
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
|
<div className="relative h-full w-full rounded-md">
|
||||||
<NoAutoplayPermissionsOverlay
|
<LoadingVideoOverlay show={isVideoLoading} />
|
||||||
show={hasNoAutoPlayPermissions}
|
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
|
||||||
onPlayClick={() => {
|
<NoAutoplayPermissionsOverlay
|
||||||
videoElm.current?.play();
|
show={hasNoAutoPlayPermissions}
|
||||||
}}
|
onPlayClick={() => {
|
||||||
/>
|
videoElm.current?.play();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VirtualKeyboard />
|
<VirtualKeyboard />
|
||||||
|
|
|
@ -1,23 +1,32 @@
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react-swc";
|
import react from "@vitejs/plugin-react-swc";
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||||
|
|
||||||
declare const process: {
|
declare const process: {
|
||||||
env: {
|
env: {
|
||||||
JETKVM_PROXY_URL: string;
|
JETKVM_PROXY_URL: string;
|
||||||
|
USE_SSL: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig(({ mode, command }) => {
|
export default defineConfig(({ mode, command }) => {
|
||||||
const isCloud = mode.indexOf("cloud") !== -1;
|
const isCloud = mode.indexOf("cloud") !== -1;
|
||||||
const onDevice = mode === "device";
|
const onDevice = mode === "device";
|
||||||
const { JETKVM_PROXY_URL } = process.env;
|
const { JETKVM_PROXY_URL, USE_SSL } = process.env;
|
||||||
|
const useSSL = USE_SSL === "true";
|
||||||
|
|
||||||
|
const plugins = [tsconfigPaths(), react()];
|
||||||
|
if (useSSL) {
|
||||||
|
plugins.push(basicSsl());
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [tsconfigPaths(), react()],
|
plugins,
|
||||||
build: { outDir: isCloud ? "dist" : "../static" },
|
build: { outDir: isCloud ? "dist" : "../static" },
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
|
https: useSSL,
|
||||||
proxy: JETKVM_PROXY_URL
|
proxy: JETKVM_PROXY_URL
|
||||||
? {
|
? {
|
||||||
"/me": JETKVM_PROXY_URL,
|
"/me": JETKVM_PROXY_URL,
|
||||||
|
|
Loading…
Reference in New Issue