mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 09:26:44 -05:00
support h264
This commit is contained in:
@@ -26,13 +26,6 @@ The configuration file path is `/etc/kvm/server.yaml`. There are two configurabl
|
||||
|
||||
## Deployment
|
||||
|
||||
```shell
|
||||
# Build
|
||||
cd server
|
||||
go mod tidy
|
||||
GOARCH=riscv64 GOOS=linux go build
|
||||
```
|
||||
Work in progress.
|
||||
|
||||
1. After the compilation is complete, the executable file `NanoKVM-Server` will be generated;
|
||||
2. Upload the `NanoKVM-Server` file to the `/kvmapp/server/` directory of NanoKVM;
|
||||
3. Execute `/etc/init.d/S95nanokvm restart` in NanoKVM to restart the service.
|
||||
The project requires CGO. It must be compiled on Linux with a toolchain installed. More details will be added later.
|
||||
@@ -25,13 +25,6 @@ server
|
||||
|
||||
## 部署
|
||||
|
||||
```shell
|
||||
# 编译
|
||||
cd server
|
||||
go mod tidy
|
||||
GOARCH=riscv64 GOOS=linux go build
|
||||
```
|
||||
待完善。
|
||||
|
||||
1. 编译完成后会生成可执行文件 `NanoKVM-Server`;
|
||||
2. 将 `NanoKVM-Server` 文件上传到 NanoKVM 的 `/kvmapp/server/` 目录下;
|
||||
3. 在 NanoKVM 中执行 `/etc/init.d/S95nanokvm restart` 重启服务。
|
||||
项目中使用了 CGO,需要使用 Linux 并安装工具链后才能编译。这部分内容会晚一点更新。
|
||||
|
||||
148
server/common/cgo.go
Normal file
148
server/common/cgo.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package common
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -I../include
|
||||
#cgo LDFLAGS: -L../dl_lib -lkvm
|
||||
#include "kvm_vision.h"
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"NanoKVM-Server/config"
|
||||
"strings"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
kvmVision *KvmVision
|
||||
kvmVisionOnce sync.Once
|
||||
)
|
||||
|
||||
type KvmVision struct {
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func GetKvmVision() *KvmVision {
|
||||
kvmVisionOnce.Do(func() {
|
||||
kvmVision = &KvmVision{}
|
||||
|
||||
conf := config.GetInstance()
|
||||
logLevel := strings.ToLower(conf.Logger.Level)
|
||||
|
||||
logEnable := C.uint8_t(0)
|
||||
if logLevel == "debug" {
|
||||
logEnable = C.uint8_t(1)
|
||||
}
|
||||
|
||||
C.kvmv_init(logEnable)
|
||||
log.Debugf("kvm vision initialized")
|
||||
})
|
||||
|
||||
return kvmVision
|
||||
}
|
||||
|
||||
func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int) {
|
||||
k.mutex.Lock()
|
||||
defer k.mutex.Unlock()
|
||||
|
||||
var (
|
||||
kvmData *C.uint8_t
|
||||
dataSize C.uint32_t
|
||||
)
|
||||
|
||||
result = int(C.kvmv_read_img(
|
||||
C.uint16_t(width),
|
||||
C.uint16_t(height),
|
||||
C.uint8_t(0),
|
||||
C.uint16_t(quality),
|
||||
&kvmData,
|
||||
&dataSize,
|
||||
))
|
||||
if result < 0 {
|
||||
log.Errorf("failed to read kvm image: %v", result)
|
||||
return
|
||||
}
|
||||
defer C.free_kvmv_data(&kvmData)
|
||||
|
||||
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
|
||||
|
||||
log.Debugf("read kvm image: %v", result)
|
||||
return
|
||||
}
|
||||
|
||||
func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data []byte, sps []byte, pps []byte, result int) {
|
||||
k.mutex.Lock()
|
||||
defer k.mutex.Unlock()
|
||||
|
||||
var (
|
||||
kvmData *C.uint8_t
|
||||
dataSize C.uint32_t
|
||||
)
|
||||
|
||||
result = int(C.kvmv_read_img(
|
||||
C.uint16_t(width),
|
||||
C.uint16_t(height),
|
||||
C.uint8_t(1),
|
||||
C.uint16_t(bitRate),
|
||||
&kvmData,
|
||||
&dataSize,
|
||||
))
|
||||
if result < 0 {
|
||||
log.Errorf("failed to read kvm image: %v", result)
|
||||
return
|
||||
}
|
||||
defer C.free_kvmv_data(&kvmData)
|
||||
|
||||
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
|
||||
|
||||
if result == 3 {
|
||||
sps, _ = k.ReadH264SPS()
|
||||
pps, _ = k.ReadH264PPS()
|
||||
}
|
||||
|
||||
log.Debugf("read kvm image: %v", result)
|
||||
return
|
||||
}
|
||||
|
||||
func (k *KvmVision) ReadH264SPS() ([]byte, int) {
|
||||
var (
|
||||
kvmData *C.uint8_t
|
||||
dataSize C.uint32_t
|
||||
)
|
||||
|
||||
result := C.kvmv_get_sps_frame(&kvmData, &dataSize)
|
||||
if result < 0 {
|
||||
log.Errorf("failed to read sps: %v", result)
|
||||
return nil, int(result)
|
||||
}
|
||||
|
||||
data := C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
|
||||
|
||||
return data, int(result)
|
||||
}
|
||||
|
||||
func (k *KvmVision) ReadH264PPS() ([]byte, int) {
|
||||
var (
|
||||
kvmData *C.uint8_t
|
||||
dataSize C.uint32_t
|
||||
)
|
||||
|
||||
result := C.kvmv_get_pps_frame(&kvmData, &dataSize)
|
||||
if result < 0 {
|
||||
log.Errorf("failed to read pps: %v", result)
|
||||
return nil, int(result)
|
||||
}
|
||||
|
||||
data := C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
|
||||
return data, int(result)
|
||||
}
|
||||
|
||||
func (k *KvmVision) Close() {
|
||||
k.mutex.Lock()
|
||||
defer k.mutex.Unlock()
|
||||
|
||||
C.kvmv_deinit()
|
||||
log.Debugf("stop kvm vision...")
|
||||
}
|
||||
85
server/common/screen.go
Normal file
85
server/common/screen.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package common
|
||||
|
||||
import "sync"
|
||||
|
||||
type Screen struct {
|
||||
Width uint16
|
||||
Height uint16
|
||||
FPS int
|
||||
Quality uint16
|
||||
BitRate uint16
|
||||
}
|
||||
|
||||
var (
|
||||
screen *Screen
|
||||
screenOnce sync.Once
|
||||
)
|
||||
|
||||
// ResolutionMap height to width
|
||||
var ResolutionMap = map[uint16]uint16{
|
||||
1080: 1920,
|
||||
720: 1280,
|
||||
600: 800,
|
||||
480: 640,
|
||||
0: 0,
|
||||
}
|
||||
|
||||
var QualityMap = map[uint16]bool{
|
||||
100: true,
|
||||
80: true,
|
||||
60: true,
|
||||
50: true,
|
||||
}
|
||||
|
||||
var BitRateMap = map[uint16]bool{
|
||||
5000: true,
|
||||
3000: true,
|
||||
2000: true,
|
||||
1000: true,
|
||||
}
|
||||
|
||||
func GetScreen() *Screen {
|
||||
screenOnce.Do(func() {
|
||||
screen = &Screen{
|
||||
Width: 0,
|
||||
Height: 0,
|
||||
Quality: 80,
|
||||
FPS: 30,
|
||||
BitRate: 3000,
|
||||
}
|
||||
})
|
||||
|
||||
return screen
|
||||
}
|
||||
|
||||
func SetScreen(key string, value int) {
|
||||
switch key {
|
||||
case "resolution":
|
||||
height := uint16(value)
|
||||
if width, ok := ResolutionMap[height]; ok {
|
||||
screen.Width = width
|
||||
screen.Height = height
|
||||
}
|
||||
|
||||
case "quality":
|
||||
if value > 100 {
|
||||
screen.BitRate = uint16(value)
|
||||
} else {
|
||||
screen.Quality = uint16(value)
|
||||
}
|
||||
|
||||
case "fps":
|
||||
screen.FPS = validateFPS(value)
|
||||
}
|
||||
}
|
||||
|
||||
func validateFPS(fps int) int {
|
||||
if fps > 60 {
|
||||
return 60
|
||||
}
|
||||
if fps < 10 {
|
||||
return 10
|
||||
}
|
||||
|
||||
return fps
|
||||
}
|
||||
@@ -18,23 +18,6 @@ import (
|
||||
var (
|
||||
config Config
|
||||
once sync.Once
|
||||
|
||||
defaultConfig = &Config{
|
||||
Protocol: "http",
|
||||
Port: Port{
|
||||
Http: 80,
|
||||
Https: 443,
|
||||
},
|
||||
Cert: Cert{
|
||||
Crt: "server.crt",
|
||||
Key: "server.key",
|
||||
},
|
||||
Logger: Logger{
|
||||
Level: "info",
|
||||
File: "stdout",
|
||||
},
|
||||
Authentication: "enable",
|
||||
}
|
||||
)
|
||||
|
||||
func GetInstance() *Config {
|
||||
|
||||
18
server/config/default.go
Normal file
18
server/config/default.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package config
|
||||
|
||||
var defaultConfig = &Config{
|
||||
Protocol: "http",
|
||||
Port: Port{
|
||||
Http: 80,
|
||||
Https: 443,
|
||||
},
|
||||
Cert: Cert{
|
||||
Crt: "server.crt",
|
||||
Key: "server.key",
|
||||
},
|
||||
Logger: Logger{
|
||||
Level: "info",
|
||||
File: "stdout",
|
||||
},
|
||||
Authentication: "enable",
|
||||
}
|
||||
BIN
server/dl_lib/libkvm.so
Normal file
BIN
server/dl_lib/libkvm.so
Normal file
Binary file not shown.
59
server/include/kvm_vision.h
Normal file
59
server/include/kvm_vision.h
Normal file
@@ -0,0 +1,59 @@
|
||||
#ifndef KVM_VISION_H_
|
||||
#define KVM_VISION_H_
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#include <fcntl.h> /* low-level i/o */
|
||||
#include <unistd.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#define IMG_BUFFER_FULL -3
|
||||
#define IMG_VENC_ERROR -2
|
||||
#define IMG_NOT_EXIST -1
|
||||
#define IMG_MJPEG_TYPE 0
|
||||
#define IMG_H264_TYPE_SPS 1
|
||||
#define IMG_H264_TYPE_PPS 2
|
||||
#define IMG_H264_TYPE_IF 3
|
||||
#define IMG_H264_TYPE_PF 4
|
||||
|
||||
void kvmv_init(uint8_t _debug_info_en);
|
||||
/**********************************************************************************
|
||||
* @name kvmv_read_img
|
||||
* @author Sipeed BuGu
|
||||
* @date 2024/10/25
|
||||
* @version R1.0
|
||||
* @brief Acquire the encoded image with auto init
|
||||
* @param _width @input: Output image width
|
||||
* @param _height @input: Output image height
|
||||
* @param _type @input: Encode type
|
||||
* @param _qlty @input: MJPEG: (50-100) | H264: (500-10000)
|
||||
* @param _pp_kvm_data @output: Encode data
|
||||
* @param _p_kvmv_data_size @output: Encode data size
|
||||
* @return
|
||||
-3: img buffer full
|
||||
-2: VENC Error
|
||||
-1: No images were acquired
|
||||
0: Acquire MJPEG encoded images
|
||||
1: Acquire H264 encoded images(SPS)
|
||||
2: Acquire H264 encoded images(PPS)
|
||||
3: Acquire H264 encoded images(I)
|
||||
4: Acquire H264 encoded images(P)
|
||||
**********************************************************************************/
|
||||
int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _qlty, uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size);
|
||||
int kvmv_get_sps_frame(uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size);
|
||||
int kvmv_get_pps_frame(uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size);
|
||||
int free_kvmv_data(uint8_t ** _pp_kvm_data);
|
||||
void free_all_kvmv_data();
|
||||
void set_h264_gop(uint8_t _gop);
|
||||
void kvmv_deinit();
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // KVM_VISION_H_
|
||||
@@ -1,19 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"NanoKVM-Server/common"
|
||||
"NanoKVM-Server/config"
|
||||
"NanoKVM-Server/logger"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/router"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
cors "github.com/rs/cors/wrapper/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger.Init()
|
||||
initialize()
|
||||
defer dispose()
|
||||
signalHandler()
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
@@ -25,6 +30,12 @@ func main() {
|
||||
run(r)
|
||||
}
|
||||
|
||||
func initialize() {
|
||||
logger.Init()
|
||||
_ = common.GetScreen()
|
||||
_ = common.GetKvmVision()
|
||||
}
|
||||
|
||||
func run(r *gin.Engine) {
|
||||
conf := config.GetInstance()
|
||||
|
||||
@@ -49,3 +60,20 @@ func run(r *gin.Engine) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
common.GetKvmVision().Close()
|
||||
}
|
||||
|
||||
func signalHandler() {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
|
||||
|
||||
go func() {
|
||||
sig := <-sigChan
|
||||
fmt.Printf("\nReceived signal: %v\n", sig)
|
||||
|
||||
dispose()
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -13,3 +13,7 @@ type ChangePasswordReq struct {
|
||||
Username string `json:"username" validate:"required"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
type IsPasswordUpdatedRsp struct {
|
||||
IsUpdated bool `json:"isUpdated"`
|
||||
}
|
||||
|
||||
@@ -13,6 +13,4 @@ func applicationRouter(r *gin.Engine) {
|
||||
|
||||
api.GET("/application/version", service.GetVersion) // get application version
|
||||
api.POST("/application/update", service.Update) // update application
|
||||
api.GET("/application/lib", service.GetLib) // check if lib exists
|
||||
api.POST("/application/lib", service.UpdateLib) // update lib
|
||||
}
|
||||
|
||||
@@ -14,5 +14,6 @@ func authRouter(r *gin.Engine) {
|
||||
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
|
||||
api.POST("/auth/password", service.ChangePassword) // change password
|
||||
api.GET("/auth/password", service.IsPasswordUpdated) // is password updated
|
||||
api.POST("/auth/password", service.ChangePassword) // change password
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/stream"
|
||||
"NanoKVM-Server/service/stream/h264"
|
||||
"NanoKVM-Server/service/stream/mjpeg"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func streamRouter(r *gin.Engine) {
|
||||
service := stream.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
|
||||
api.GET("/stream/mjpeg", service.Mjpeg) // mjpeg stream
|
||||
api.GET("/stream/mjpeg", mjpeg.Connect) // mjpeg stream
|
||||
api.GET("/stream/mjpeg/detect", mjpeg.GetFrameDetect) // get frame detect state
|
||||
api.POST("/stream/mjpeg/detect", mjpeg.UpdateFrameDetect) // update frame detect state
|
||||
api.POST("/stream/mjpeg/detect/stop", mjpeg.StopFrameDetect) // temporary stop frame detect
|
||||
|
||||
api.GET("/stream/mjpeg/detect", service.GetFrameDetect) // get frame detect state
|
||||
api.POST("/stream/mjpeg/detect", service.UpdateFrameDetect) // update frame detect state
|
||||
api.POST("/stream/mjpeg/detect/stop", service.StopFrameDetect) // temporary stop frame detect
|
||||
api.GET("/stream/h264", h264.Connect) // h264 stream
|
||||
}
|
||||
|
||||
@@ -173,3 +173,39 @@ func downloadApp() error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func downloadLib() error {
|
||||
log.Debugf("downloading libs...")
|
||||
content, err := os.ReadFile("/device_key")
|
||||
if err != nil {
|
||||
log.Errorf("error reading device key: %s", err)
|
||||
return err
|
||||
}
|
||||
deviceKey := strings.ReplaceAll(string(content), "\n", "")
|
||||
|
||||
for i := range maxTries {
|
||||
log.Debugf("attempt #%d/%d", i+1, maxTries)
|
||||
if i > 0 {
|
||||
time.Sleep(time.Second * 3) // wait for 3 seconds before retrying the download attempt
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
url := fmt.Sprintf("%s?uid=%s", libURL, deviceKey)
|
||||
req, err = http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
log.Errorf("error creating new request: %s", err)
|
||||
continue
|
||||
}
|
||||
req.Header.Set("token", "MaixVision2024")
|
||||
|
||||
target := fmt.Sprintf("%s/%s", temporary, libName)
|
||||
|
||||
err = utils.Download(req, target)
|
||||
if err != nil {
|
||||
log.Errorf("downloading lib failed: %s", err)
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/utils"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (s *Service) GetLib(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
exist, err := isLibExist()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "check lib error")
|
||||
return
|
||||
}
|
||||
|
||||
data := &proto.GetLibRsp{
|
||||
Exist: exist,
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, data)
|
||||
log.Debugf("get lib success, exist: %t", exist)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateLib(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
exist, _ := isLibExist()
|
||||
if exist {
|
||||
rsp.OkRsp(c)
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(temporary, 0o755)
|
||||
defer func() {
|
||||
_ = os.RemoveAll(temporary)
|
||||
}()
|
||||
|
||||
if err := downloadLib(); err != nil {
|
||||
rsp.ErrRsp(c, -1, "download lib failed")
|
||||
return
|
||||
}
|
||||
|
||||
err := utils.MoveFile(temporary+"/"+libName, libDir+"/"+libName) // update lib
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, "update lib failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("update lib success")
|
||||
|
||||
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
|
||||
}
|
||||
|
||||
func isLibExist() (bool, error) {
|
||||
libPath := fmt.Sprintf("%s/%s", libDir, libName)
|
||||
_, err := os.Stat(libPath)
|
||||
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
func downloadLib() error {
|
||||
log.Debugf("downloading libs...")
|
||||
content, err := os.ReadFile("/device_key")
|
||||
if err != nil {
|
||||
log.Errorf("error reading device key: %s", err)
|
||||
return err
|
||||
}
|
||||
deviceKey := strings.ReplaceAll(string(content), "\n", "")
|
||||
|
||||
for i := range maxTries {
|
||||
log.Debugf("attempt #%d/%d", i+1, maxTries)
|
||||
if i > 0 {
|
||||
time.Sleep(time.Second * 3) // wait for 3 seconds before retrying the download attempt
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
url := fmt.Sprintf("%s?uid=%s", libURL, deviceKey)
|
||||
req, err = http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
log.Errorf("error creating new request: %s", err)
|
||||
continue
|
||||
}
|
||||
req.Header.Set("token", "MaixVision2024")
|
||||
|
||||
target := fmt.Sprintf("%s/%s", temporary, libName)
|
||||
|
||||
err = utils.Download(req, target)
|
||||
if err != nil {
|
||||
log.Errorf("downloading lib failed: %s", err)
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -5,10 +5,9 @@ const (
|
||||
applicationURL = "https://cdn.sipeed.com/nanokvm/latest.zip"
|
||||
libURL = "https://maixvision.sipeed.com/api/v1/nanokvm/encryption"
|
||||
|
||||
temporary = "/tmp/kvmcache"
|
||||
temporary = "/root/.kvmcache"
|
||||
workspace = "/kvmapp"
|
||||
backup = "/root/old"
|
||||
libDir = "/kvmapp/kvm_system/dl_lib"
|
||||
libName = "libmaixcam_lib.so"
|
||||
versionFile = "/kvmapp/version"
|
||||
)
|
||||
|
||||
@@ -75,3 +75,22 @@ func (s *Service) ChangePassword(c *gin.Context) {
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("change password success, username: %s", req.Username)
|
||||
}
|
||||
|
||||
func (s *Service) IsPasswordUpdated(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
account, err := getAccount()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "failed to get password")
|
||||
}
|
||||
|
||||
isUpdated := true
|
||||
if account == nil || account.Password == "admin" {
|
||||
isUpdated = false
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{
|
||||
IsUpdated: isUpdated,
|
||||
})
|
||||
log.Debugf("get password success")
|
||||
}
|
||||
|
||||
63
server/service/stream/frame-rate.go
Normal file
63
server/service/stream/frame-rate.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
counter *FrameRateCounter
|
||||
counterOnce sync.Once
|
||||
)
|
||||
|
||||
type FrameRateCounter struct {
|
||||
frameCount int32
|
||||
fps int32
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func GetFrameRateCounter() *FrameRateCounter {
|
||||
counterOnce.Do(func() {
|
||||
counter = &FrameRateCounter{}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
counter.mutex.Lock()
|
||||
|
||||
currentCount := atomic.LoadInt32(&counter.frameCount)
|
||||
|
||||
counter.fps = currentCount / 3
|
||||
atomic.StoreInt32(&counter.frameCount, 0)
|
||||
|
||||
counter.mutex.Unlock()
|
||||
|
||||
data := fmt.Sprintf("%d", counter.fps)
|
||||
err := os.WriteFile("/kvmapp/kvm/now_fps", []byte(data), 0o666)
|
||||
if err != nil {
|
||||
log.Errorf("failed to wirte fps: %s", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
return counter
|
||||
}
|
||||
|
||||
func (f *FrameRateCounter) Update() {
|
||||
atomic.AddInt32(&f.frameCount, 1)
|
||||
}
|
||||
|
||||
func (f *FrameRateCounter) GetFPS() int32 {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
|
||||
return f.fps
|
||||
}
|
||||
173
server/service/stream/h264/client.go
Normal file
173
server/service/stream/h264/client.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package h264
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/webrtc/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
ws *websocket.Conn
|
||||
pc *webrtc.PeerConnection
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Event string `json:"event"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// add video track
|
||||
func (c *Client) addTrack() {
|
||||
videoTrack, err := webrtc.NewTrackLocalStaticSample(
|
||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
|
||||
"video",
|
||||
"pion",
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create video track: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = c.pc.AddTrack(videoTrack)
|
||||
if err != nil {
|
||||
log.Errorf("failed to add video track: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
mutex.Lock()
|
||||
trackMap[c.ws] = videoTrack
|
||||
mutex.Unlock()
|
||||
}
|
||||
|
||||
// register callback events
|
||||
func (c *Client) register() {
|
||||
// new ICE candidate found
|
||||
c.pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate == nil {
|
||||
return
|
||||
}
|
||||
|
||||
candidateByte, err := json.Marshal(candidate.ToJSON())
|
||||
if err != nil {
|
||||
log.Errorf("failed to marshal candidate: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = c.sendMessage("candidate", string(candidateByte))
|
||||
})
|
||||
|
||||
// ICE connection state has changed
|
||||
c.pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
|
||||
if state == webrtc.ICEConnectionStateConnected && !isSending {
|
||||
mutex.Lock()
|
||||
if !isSending {
|
||||
// start sending h264 data
|
||||
go send()
|
||||
isSending = true
|
||||
}
|
||||
mutex.Unlock()
|
||||
}
|
||||
|
||||
log.Debugf("ice connection state has changed to %s", state.String())
|
||||
})
|
||||
}
|
||||
|
||||
// read websocket message
|
||||
func (c *Client) readMessage() {
|
||||
message := &Message{}
|
||||
|
||||
for {
|
||||
_, raw, err := c.ws.ReadMessage()
|
||||
if err != nil {
|
||||
mutex.Lock()
|
||||
delete(trackMap, c.ws)
|
||||
if len(trackMap) == 0 && isSending {
|
||||
// stop sending when all websocket connections are closed
|
||||
exitSig <- true
|
||||
}
|
||||
mutex.Unlock()
|
||||
|
||||
log.Debugf("failed to read message: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(raw, &message); err != nil {
|
||||
log.Errorf("failed to unmarshal message: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debugf("receive message event: %s", message.Event)
|
||||
|
||||
switch message.Event {
|
||||
case "offer":
|
||||
offer := webrtc.SessionDescription{}
|
||||
if err := json.Unmarshal([]byte(message.Data), &offer); err != nil {
|
||||
log.Errorf("failed to unmarshal offer message: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.pc.SetRemoteDescription(offer); err != nil {
|
||||
log.Errorf("failed to set remote description: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
answer, answerErr := c.pc.CreateAnswer(nil)
|
||||
if answerErr != nil {
|
||||
log.Errorf("failed to create answer: %s", answerErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.pc.SetLocalDescription(answer); err != nil {
|
||||
log.Errorf("failed to set local description: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
answerByte, answerByteErr := json.Marshal(answer)
|
||||
if answerByteErr != nil {
|
||||
log.Errorf("failed to marshal answer: %s", answerByteErr)
|
||||
return
|
||||
}
|
||||
|
||||
_ = c.sendMessage("answer", string(answerByte))
|
||||
|
||||
case "candidate":
|
||||
candidate := webrtc.ICECandidateInit{}
|
||||
if err := json.Unmarshal([]byte(message.Data), &candidate); err != nil {
|
||||
log.Errorf("failed to unmarshal candidate message: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.pc.AddICECandidate(candidate); err != nil {
|
||||
log.Errorf("failed to add ICE candidate: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
case "heartbeat":
|
||||
_ = c.sendMessage("heartbeat", "")
|
||||
|
||||
default:
|
||||
log.Debugf("unhandled message event: %s", message.Event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send websocket message
|
||||
func (c *Client) sendMessage(event string, data string) error {
|
||||
mutex.RLock()
|
||||
defer mutex.RUnlock()
|
||||
|
||||
message := &Message{
|
||||
Event: event,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
if err := c.ws.WriteJSON(message); err != nil {
|
||||
log.Errorf("failed to send message %s: %s", event, err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("send message %s", message.Event)
|
||||
return nil
|
||||
}
|
||||
64
server/service/stream/h264/h264.go
Normal file
64
server/service/stream/h264/h264.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package h264
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/webrtc/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
trackMap = make(map[*websocket.Conn]*webrtc.TrackLocalStaticSample)
|
||||
mutex = sync.RWMutex{}
|
||||
isSending = false
|
||||
exitSig = make(chan bool, 1)
|
||||
)
|
||||
|
||||
func Connect(c *gin.Context) {
|
||||
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create websocket: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = wsConn.Close()
|
||||
log.Debugf("h264 websocket disconnected")
|
||||
}()
|
||||
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
peerConn, err := webrtc.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create PeerConnection: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = peerConn.Close()
|
||||
log.Debugf("PeerConnection disconnected")
|
||||
}()
|
||||
|
||||
client := &Client{
|
||||
ws: wsConn,
|
||||
pc: peerConn,
|
||||
}
|
||||
|
||||
client.addTrack()
|
||||
client.register()
|
||||
client.readMessage()
|
||||
}
|
||||
80
server/service/stream/h264/sender.go
Normal file
80
server/service/stream/h264/sender.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package h264
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/common"
|
||||
"NanoKVM-Server/service/stream"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func send() {
|
||||
screen := common.GetScreen()
|
||||
fps := screen.FPS
|
||||
duration := time.Second / time.Duration(fps)
|
||||
|
||||
ticker := time.NewTicker(duration)
|
||||
defer ticker.Stop()
|
||||
|
||||
vision := common.GetKvmVision()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
height := screen.Height
|
||||
width, ok := common.ResolutionMap[height]
|
||||
if !ok {
|
||||
width = 0
|
||||
height = 0
|
||||
}
|
||||
|
||||
bitRate := screen.BitRate
|
||||
if _, ok := common.BitRateMap[bitRate]; !ok {
|
||||
bitRate = 3000
|
||||
}
|
||||
|
||||
data, sps, pps, result := vision.ReadH264(width, height, bitRate)
|
||||
if result < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if result == 3 {
|
||||
writeSample(sps, duration)
|
||||
writeSample(pps, duration)
|
||||
}
|
||||
writeSample(data, duration)
|
||||
|
||||
stream.GetFrameRateCounter().Update()
|
||||
|
||||
if screen.FPS != fps {
|
||||
fps = screen.FPS
|
||||
duration = time.Second / time.Duration(fps)
|
||||
ticker.Reset(duration)
|
||||
}
|
||||
|
||||
case <-exitSig:
|
||||
mutex.Lock()
|
||||
isSending = false
|
||||
mutex.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeSample(data []byte, duration time.Duration) {
|
||||
sample := media.Sample{
|
||||
Data: data,
|
||||
Duration: duration,
|
||||
}
|
||||
|
||||
mutex.RLock()
|
||||
defer mutex.RUnlock()
|
||||
|
||||
for _, track := range trackMap {
|
||||
if err := track.WriteSample(sample); err != nil {
|
||||
log.Errorf("failed to send h264 data: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("send h264 data: %d", len(data))
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (s *Service) Mjpeg(c *gin.Context) {
|
||||
mjpegURL := "http://127.0.0.1:8000/stream"
|
||||
|
||||
resp, err := http.Get(mjpegURL)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to connect to MJPEG server")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.String(resp.StatusCode, "MJPEG server returned an error")
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", resp.Header.Get("Content-Type"))
|
||||
|
||||
_, err = io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to copy MJPEG stream")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package stream
|
||||
package mjpeg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -20,7 +20,7 @@ type UpdateFrameDetectRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *Service) GetFrameDetect(c *gin.Context) {
|
||||
func GetFrameDetect(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
isEnabled, err := isFrameDetectEnabled()
|
||||
@@ -35,7 +35,7 @@ func (s *Service) GetFrameDetect(c *gin.Context) {
|
||||
log.Debugf("get frame detect success, enabled: %t", isEnabled)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateFrameDetect(c *gin.Context) {
|
||||
func UpdateFrameDetect(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
isEnabled, err := isFrameDetectEnabled()
|
||||
@@ -70,7 +70,7 @@ func (s *Service) UpdateFrameDetect(c *gin.Context) {
|
||||
log.Debugf("update frame detect success, enabled: %t", isEnabled)
|
||||
}
|
||||
|
||||
func (s *Service) StopFrameDetect(c *gin.Context) {
|
||||
func StopFrameDetect(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
exist, err := isFileExist(frameDetect)
|
||||
111
server/service/stream/mjpeg/mjpeg.go
Normal file
111
server/service/stream/mjpeg/mjpeg.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package mjpeg
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/common"
|
||||
"NanoKVM-Server/service/stream"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
chanMap = make(map[*gin.Context]chan struct{})
|
||||
mutex = sync.RWMutex{}
|
||||
exitSig = make(chan bool, 1)
|
||||
)
|
||||
|
||||
func Connect(c *gin.Context) {
|
||||
c.Header("Content-Type", fmt.Sprintf("multipart/x-mixed-replace; boundary=frame"))
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Pragma", "no-cache")
|
||||
|
||||
mutex.Lock()
|
||||
chanMap[c] = make(chan struct{}, 1)
|
||||
if len(chanMap) == 1 {
|
||||
go send()
|
||||
}
|
||||
mutex.Unlock()
|
||||
|
||||
<-chanMap[c]
|
||||
|
||||
mutex.Lock()
|
||||
delete(chanMap, c)
|
||||
if len(chanMap) == 0 {
|
||||
exitSig <- true
|
||||
}
|
||||
mutex.Unlock()
|
||||
}
|
||||
|
||||
func send() {
|
||||
screen := common.GetScreen()
|
||||
fps := screen.FPS
|
||||
|
||||
ticker := time.NewTicker(time.Second / time.Duration(fps))
|
||||
defer ticker.Stop()
|
||||
|
||||
vision := common.GetKvmVision()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
height := screen.Height
|
||||
width, ok := common.ResolutionMap[height]
|
||||
if !ok {
|
||||
width = 0
|
||||
height = 0
|
||||
}
|
||||
|
||||
quality := screen.Quality
|
||||
if _, ok := common.QualityMap[quality]; !ok {
|
||||
quality = 80
|
||||
}
|
||||
|
||||
data, result := vision.ReadMjpeg(width, height, quality)
|
||||
if result < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for c, ch := range chanMap {
|
||||
if err := write(c, data); err != nil {
|
||||
log.Debugf("failed to write mjpeg data: %s", err)
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
log.Debugf("send mjpeg data: %d", len(data))
|
||||
|
||||
stream.GetFrameRateCounter().Update()
|
||||
|
||||
if screen.FPS != fps {
|
||||
fps = screen.FPS
|
||||
ticker.Reset(time.Second / time.Duration(fps))
|
||||
}
|
||||
|
||||
case <-exitSig:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func write(c *gin.Context, data []byte) (err error) {
|
||||
if _, err = c.Writer.Write([]byte("--frame\r\n")); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = c.Writer.Write([]byte("Content-Type: image/jpeg\r\n\r\n")); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = c.Writer.Write(data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = c.Writer.Write([]byte("\r\n")); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.Flush()
|
||||
return
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package stream
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/common"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
)
|
||||
|
||||
var screenFileMap = map[string]string{
|
||||
"type": "/kvmapp/kvm/type",
|
||||
"fps": "/kvmapp/kvm/fps",
|
||||
"quality": "/kvmapp/kvm/qlty",
|
||||
"resolution": "/kvmapp/kvm/res",
|
||||
@@ -32,6 +34,14 @@ func (s *Service) SetScreen(c *gin.Context) {
|
||||
}
|
||||
|
||||
data := fmt.Sprintf("%d", req.Value)
|
||||
if req.Type == "type" {
|
||||
if req.Value == 0 {
|
||||
data = "mjpeg"
|
||||
} else {
|
||||
data = "h264"
|
||||
}
|
||||
}
|
||||
|
||||
err := os.WriteFile(file, []byte(data), 0o666)
|
||||
if err != nil {
|
||||
log.Errorf("write kvm %s failed: %s", file, err)
|
||||
@@ -39,6 +49,8 @@ func (s *Service) SetScreen(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
common.SetScreen(req.Type, req.Value)
|
||||
|
||||
log.Debugf("update screen: %+v", req)
|
||||
rsp.OkRsp(c)
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
StreamState = "/kvmapp/kvm/state"
|
||||
)
|
||||
|
||||
func watchStreamState(fileModMap map[string]time.Time) ([]byte, error) {
|
||||
content, lastModTime, err := readFile(StreamState, fileModMap[StreamState])
|
||||
fileModMap[StreamState] = lastModTime
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("read file %s failed: %s", StreamState, err)
|
||||
return nil, err
|
||||
}
|
||||
if content == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
contentStr := strings.ReplaceAll(string(content), "\n", "")
|
||||
state, err := strconv.Atoi(contentStr)
|
||||
if err != nil {
|
||||
log.Errorf("parse stream state failed: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
message, err := json.Marshal(&Stream{
|
||||
Type: "stream",
|
||||
State: state,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("parse stream failed: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func readFile(filename string, lastModTime time.Time) ([]byte, time.Time, error) {
|
||||
file, err := os.Stat(filename)
|
||||
if err != nil {
|
||||
return nil, lastModTime, err
|
||||
}
|
||||
|
||||
if !file.ModTime().After(lastModTime) {
|
||||
return nil, lastModTime, nil
|
||||
}
|
||||
|
||||
if lastModTime.Equal(time.Unix(0, 0)) {
|
||||
return nil, file.ModTime(), nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, file.ModTime(), err
|
||||
}
|
||||
|
||||
return content, file.ModTime(), nil
|
||||
}
|
||||
@@ -22,7 +22,6 @@ type WsClient struct {
|
||||
hid *hid.Hid
|
||||
keyboard chan []int
|
||||
mouse chan []int
|
||||
watcher chan struct{}
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
@@ -46,7 +45,6 @@ func (s *Service) Connect(c *gin.Context) {
|
||||
conn: conn,
|
||||
keyboard: make(chan []int, 200),
|
||||
mouse: make(chan []int, 200),
|
||||
watcher: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
go client.Start()
|
||||
@@ -60,8 +58,6 @@ func (c *WsClient) Start() {
|
||||
go c.hid.Keyboard(c.keyboard)
|
||||
go c.hid.Mouse(c.mouse)
|
||||
|
||||
go c.Watch()
|
||||
|
||||
_ = c.Read()
|
||||
}
|
||||
|
||||
@@ -97,35 +93,6 @@ func (c *WsClient) Write(message []byte) error {
|
||||
return c.conn.WriteMessage(websocket.TextMessage, message)
|
||||
}
|
||||
|
||||
func (c *WsClient) Watch() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
fileModMap := map[string]time.Time{
|
||||
StreamState: time.Unix(0, 0),
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
{
|
||||
message, err := watchStreamState(fileModMap)
|
||||
if err != nil || message == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
err = c.Write(message)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case <-c.watcher:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WsClient) Clean() {
|
||||
_ = c.conn.Close()
|
||||
|
||||
@@ -135,8 +102,6 @@ func (c *WsClient) Clean() {
|
||||
go clearQueue(c.mouse)
|
||||
close(c.mouse)
|
||||
|
||||
close(c.watcher)
|
||||
|
||||
c.hid.Close()
|
||||
|
||||
log.Debug("websocket disconnected")
|
||||
|
||||
@@ -13,17 +13,3 @@ export function update() {
|
||||
timeout: 15 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
// check if lib exists
|
||||
export function getLib() {
|
||||
return http.get('/api/application/lib');
|
||||
}
|
||||
|
||||
// download lib
|
||||
export function updateLib() {
|
||||
return http.request({
|
||||
method: 'post',
|
||||
url: '/api/application/lib',
|
||||
timeout: 15 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,3 +15,7 @@ export function changePassword(username: string, password: string) {
|
||||
};
|
||||
return http.post('/api/auth/password', data);
|
||||
}
|
||||
|
||||
export function isPasswordUpdated() {
|
||||
return http.get('/api/auth/password');
|
||||
}
|
||||
|
||||
@@ -28,10 +28,13 @@ const cz = {
|
||||
placeholderPassword2: 'Zadejte prosím heslo znovu',
|
||||
noEmptyUsername: 'Uživatelské jméno nesmí být prázdné',
|
||||
noEmptyPassword: 'Heslo nesmí být prázdné',
|
||||
noAccount: 'Nepodařilo se získat informace o uživateli, prosím obnovte stránku nebo resetujte heslo',
|
||||
noAccount:
|
||||
'Nepodařilo se získat informace o uživateli, prosím obnovte stránku nebo resetujte heslo',
|
||||
invalidUser: 'Neplatné uživatelské jméno nebo heslo',
|
||||
error: 'Neočekávaná chyba',
|
||||
changePassword: 'Změnit heslo',
|
||||
changePasswordDesc:
|
||||
'Pro bezpečnost vašeho zařízení prosím změňte heslo pro přihlášení na webu.',
|
||||
differentPassword: 'Hesla se neshodují',
|
||||
illegalUsername: 'Uživatelské jméno obsahuje nepovolené znaky',
|
||||
illegalPassword: 'Heslo obsahuje nepovolené znaky',
|
||||
@@ -45,6 +48,7 @@ const cz = {
|
||||
cancel: 'Zrušit'
|
||||
},
|
||||
screen: {
|
||||
video: 'Režim videa',
|
||||
resolution: 'Rozlišení',
|
||||
auto: 'Automatické',
|
||||
autoTips:
|
||||
@@ -52,6 +56,10 @@ const cz = {
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Přizpůsobit',
|
||||
quality: 'Kvalita',
|
||||
qualityLossless: 'Bezeztrátový',
|
||||
qualityHigh: 'Vysoký',
|
||||
qualityMedium: 'Střední',
|
||||
qualityLow: 'Nízký',
|
||||
frameDetect: 'Detekce snímků',
|
||||
frameDetectTip:
|
||||
'Vypočítá rozdíl mezi snímky. Přenos video streamu se zastaví, pokud nejsou detekovány změny na obrazovce vzdáleného hostitele.'
|
||||
@@ -73,7 +81,8 @@ const cz = {
|
||||
mode: 'Režim myši',
|
||||
absolute: 'Absolutní režim',
|
||||
relative: 'Relativní režim',
|
||||
requestPointer: 'Používá se relativní režim. Klikněte prosím na plochu pro získání kurzoru myši.',
|
||||
requestPointer:
|
||||
'Používá se relativní režim. Klikněte prosím na plochu pro získání kurzoru myši.',
|
||||
resetHid: 'Resetovat HID'
|
||||
},
|
||||
image: {
|
||||
@@ -165,7 +174,8 @@ const cz = {
|
||||
upTailscale: 'Nahrajte Tailscale do adresáře NanoKVM /usr/bin/',
|
||||
upTailscaled: 'Nahrajte Tailscaled do adresáře NanoKVM /usr/sbin/',
|
||||
refresh: 'Obnovit stránku',
|
||||
notLogin: 'Zařízení nebylo dosud spárováno. Přihlaste se prosím a spárujte toto zařízení s vaším účtem.',
|
||||
notLogin:
|
||||
'Zařízení nebylo dosud spárováno. Přihlaste se prosím a spárujte toto zařízení s vaším účtem.',
|
||||
urlPeriod: 'Tento odkaz je platný po dobu 10 minut',
|
||||
login: 'Přihlášení',
|
||||
loginSuccess: 'Přihlášení úspěšné',
|
||||
|
||||
@@ -28,10 +28,12 @@ const da = {
|
||||
placeholderPassword2: 'indtast adgangskode igen',
|
||||
noEmptyUsername: 'brugernavn kan ikke være tom',
|
||||
noEmptyPassword: 'adgangskode kan ikke være tom',
|
||||
noAccount: 'Kunne ikke hente brugeroplysninger. Prøv at opdater siden eller nulstil adgangskoden',
|
||||
noAccount:
|
||||
'Kunne ikke hente brugeroplysninger. Prøv at opdater siden eller nulstil adgangskoden',
|
||||
invalidUser: 'ugyldigt brugernavn eller adgangskode',
|
||||
error: 'uventet fejl',
|
||||
changePassword: 'Skift adgangskode',
|
||||
changePasswordDesc: 'For sikkerheden af din enhed, bedes du ændre web-login adgangskoden.',
|
||||
differentPassword: 'Adgangskoder er ikke ens',
|
||||
illegalUsername: 'brugernavn indeholder ugyldige tegn',
|
||||
illegalPassword: 'adgangskode indeholder ugyldige tegn',
|
||||
@@ -45,16 +47,21 @@ const da = {
|
||||
cancel: 'Annuller'
|
||||
},
|
||||
screen: {
|
||||
video: 'Videotilstand',
|
||||
resolution: 'Opløsning',
|
||||
auto: 'Automatisk',
|
||||
autoTips:
|
||||
"Screen-tearing eller mouse-offset kan opstå ved enkelte opløsninger. Hvis du oplever dette, kan du prøve at justere fjerncomputerens skærmopløsning eller deaktivere automatisk tilstand.",
|
||||
'Screen-tearing eller mouse-offset kan opstå ved enkelte opløsninger. Hvis du oplever dette, kan du prøve at justere fjerncomputerens skærmopløsning eller deaktivere automatisk tilstand.',
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Tilpas',
|
||||
quality: 'Kvalitet',
|
||||
qualityLossless: 'Tabsfri',
|
||||
qualityHigh: 'Høj',
|
||||
qualityMedium: 'Mellem',
|
||||
qualityLow: 'Lav',
|
||||
frameDetect: 'Beregn frames',
|
||||
frameDetectTip:
|
||||
"Beregner forskellen mellem hver frame. Stopper med at sende et video stream hvis der ikke registreres ændringer på fjerncomputerens skærm."
|
||||
'Beregner forskellen mellem hver frame. Stopper med at sende et video stream hvis der ikke registreres ændringer på fjerncomputerens skærm.'
|
||||
},
|
||||
keyboard: {
|
||||
paste: 'Indsæt',
|
||||
@@ -82,7 +89,7 @@ const da = {
|
||||
empty: 'Ingen fundet',
|
||||
mountFailed: 'Montering af diskbillede mislykkedes',
|
||||
mountDesc:
|
||||
"På nogle systemer kan det være nødvendigt at skubbe den virtuelle disk ud på fjerncomputeren før du kan montere diskbilledet.",
|
||||
'På nogle systemer kan det være nødvendigt at skubbe den virtuelle disk ud på fjerncomputeren før du kan montere diskbilledet.',
|
||||
tips: {
|
||||
title: 'Sådan uploader du',
|
||||
usb1: 'Forbind din NanoKVM til din computer via USB.',
|
||||
@@ -165,7 +172,8 @@ const da = {
|
||||
upTailscale: 'Upload tailscale til følgende mappe på enheden: /usr/bin/',
|
||||
upTailscaled: 'Upload tailscaled til følgende mappe på enheden: /usr/sbin/',
|
||||
refresh: 'Opdater sides',
|
||||
notLogin: 'Enheden er ikke tilknyttet en Tailscale-konto endnu. Log ind for at fuldføre tilknytningen til din konto.',
|
||||
notLogin:
|
||||
'Enheden er ikke tilknyttet en Tailscale-konto endnu. Log ind for at fuldføre tilknytningen til din konto.',
|
||||
urlPeriod: 'Denne URL er gyldig i 10 minutter',
|
||||
login: 'Log ind',
|
||||
loginSuccess: 'Log ind lykkedes',
|
||||
|
||||
@@ -34,6 +34,8 @@ const de = {
|
||||
invalidUser: 'Falscher Benutzername oder falsches Passwort',
|
||||
error: 'Unerwarteter Fehler',
|
||||
changePassword: 'Passwort ändern',
|
||||
changePasswordDesc:
|
||||
'Für die Sicherheit Ihres Geräts ändern Sie bitte das Web-Login-Passwort.',
|
||||
differentPassword: 'Passwörter stimmt nicht überein',
|
||||
illegalUsername: 'Benutzername beinhaltet ungültige Zeichen',
|
||||
illegalPassword: 'Passwort beinhaltet ungültige Zeichen',
|
||||
@@ -48,6 +50,7 @@ const de = {
|
||||
cancel: 'Abbrechen'
|
||||
},
|
||||
screen: {
|
||||
video: 'Videomodus',
|
||||
resolution: 'Auflösung',
|
||||
auto: 'Automatisch',
|
||||
autoTips:
|
||||
@@ -55,6 +58,10 @@ const de = {
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Anpassen',
|
||||
quality: 'Qualität',
|
||||
qualityLossless: 'Verlustfrei',
|
||||
qualityHigh: 'Hoch',
|
||||
qualityMedium: 'Mittel',
|
||||
qualityLow: 'Niedrig',
|
||||
frameDetect: 'Frame Detect',
|
||||
frameDetectTip:
|
||||
'Berechnet den Unterschied zwischen den Einzelbildern. Beendet die Liveübertragung des Videostreams wenn keine Änderungen auf dem Bildschirm des Hosts festgestellt werden kann.'
|
||||
|
||||
@@ -32,6 +32,7 @@ const en = {
|
||||
invalidUser: 'Invalid username or password',
|
||||
error: 'Unexpected error',
|
||||
changePassword: 'Change Password',
|
||||
changePasswordDesc: 'For the security of your device, please modify the web login password.',
|
||||
differentPassword: 'Passwords do not match',
|
||||
illegalUsername: 'Username contains illegal characters',
|
||||
illegalPassword: 'Password contains illegal characters',
|
||||
@@ -45,6 +46,7 @@ const en = {
|
||||
cancel: 'Cancel'
|
||||
},
|
||||
screen: {
|
||||
video: 'Video Mode',
|
||||
resolution: 'Resolution',
|
||||
auto: 'Automatic',
|
||||
autoTips:
|
||||
@@ -52,6 +54,10 @@ const en = {
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Customize',
|
||||
quality: 'Quality',
|
||||
qualityLossless: 'Lossless',
|
||||
qualityHigh: 'High',
|
||||
qualityMedium: 'Medium',
|
||||
qualityLow: 'Low',
|
||||
frameDetect: 'Frame Detect',
|
||||
frameDetectTip:
|
||||
"Calculate the difference between frames. Stop transmitting video stream when no changes are detected on the remote host's screen."
|
||||
|
||||
@@ -28,16 +28,20 @@ const en = {
|
||||
placeholderPassword2: 'Introduce tu contraseña de nuevo',
|
||||
noEmptyUsername: 'El usuario no puede estar vacío',
|
||||
noEmptyPassword: 'La contraseña no puede estar vacía',
|
||||
noAccount: 'No se ha encontrado la cuenta. Por favor, recarga la página o recupera tu contraseña.',
|
||||
noAccount:
|
||||
'No se ha encontrado la cuenta. Por favor, recarga la página o recupera tu contraseña.',
|
||||
invalidUser: 'Usuario o contraseña incorrectos',
|
||||
error: 'Error inesperado',
|
||||
changePassword: 'Cambiar contraseña',
|
||||
differentPassword: 'Las contraseñas no coinciden',
|
||||
changePasswordDesc:
|
||||
'Para la seguridad de su dispositivo, por favor modifique la contraseña de inicio de sesión en la web.',
|
||||
illegalUsername: 'El usuario contiene caracteres no permitidos',
|
||||
illegalPassword: 'La contraseña contiene caracteres no permitidos',
|
||||
forgetPassword: 'Contraseña olvidada',
|
||||
resetPassword: 'Reiniciar contraseña',
|
||||
reset1: 'Si has olvidado tu contraseña, por favor, sigue los siguientes pasos para recuperarla:',
|
||||
reset1:
|
||||
'Si has olvidado tu contraseña, por favor, sigue los siguientes pasos para recuperarla:',
|
||||
reset2: '1. Inicia sesión en el dispositivo NanoKVM a través de SSH',
|
||||
reset3: '2. Elimina este archivo en el dispositivo: ',
|
||||
reset4: '3. Utiliza la cuenta por defecto para iniciar sesión: ',
|
||||
@@ -45,14 +49,21 @@ const en = {
|
||||
cancel: 'Cancelar'
|
||||
},
|
||||
screen: {
|
||||
video: 'Modo de vídeo',
|
||||
resolution: 'Resolución',
|
||||
auto: 'Automático',
|
||||
autoTips: "En determinadas resoluciones pueden producirse rasgado de imagen o desplazamiento del ratón. Prueba a ajustar la resolución del host remoto o desactiva el modo automático.",
|
||||
autoTips:
|
||||
'En determinadas resoluciones pueden producirse rasgado de imagen o desplazamiento del ratón. Prueba a ajustar la resolución del host remoto o desactiva el modo automático.',
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Personalizar',
|
||||
quality: 'Calidad',
|
||||
qualityLossless: 'Sin pérdida',
|
||||
qualityHigh: 'Alto',
|
||||
qualityMedium: 'Medio',
|
||||
qualityLow: 'Bajo',
|
||||
frameDetect: 'Detectar fotogramas',
|
||||
frameDetectTip: "Calcula la diferencia entre fotogramas. Para de transmitir vídeo cuando no se detectan cambios en la pantalla del host remoto."
|
||||
frameDetectTip:
|
||||
'Calcula la diferencia entre fotogramas. Para de transmitir vídeo cuando no se detectan cambios en la pantalla del host remoto.'
|
||||
},
|
||||
keyboard: {
|
||||
paste: 'Pegar',
|
||||
@@ -71,7 +82,8 @@ const en = {
|
||||
mode: 'Modo de ratón',
|
||||
absolute: 'Modo absoluto',
|
||||
relative: 'Modo relativo',
|
||||
requestPointer: 'Usando modo relativo. Por favor, haz clic en el escritorio para obtener el cursor del ratón.',
|
||||
requestPointer:
|
||||
'Usando modo relativo. Por favor, haz clic en el escritorio para obtener el cursor del ratón.',
|
||||
resetHid: 'Restablecer HID'
|
||||
},
|
||||
image: {
|
||||
@@ -79,7 +91,8 @@ const en = {
|
||||
loading: 'Cargando...',
|
||||
empty: 'No se ha encontrado nada',
|
||||
mountFailed: 'Fallo al montar',
|
||||
mountDesc: "En algunos sistemas, es necesario expulsar el disco virtual en el host remoto antes de montar una imagen.",
|
||||
mountDesc:
|
||||
'En algunos sistemas, es necesario expulsar el disco virtual en el host remoto antes de montar una imagen.',
|
||||
tips: {
|
||||
title: 'Cómo subir imágenes',
|
||||
usb1: 'Conecta el NanoKVM a tu computadora mediante USB.',
|
||||
@@ -144,7 +157,7 @@ const en = {
|
||||
updating: 'Actualización iniciada. Por favor, espera...',
|
||||
confirm: 'Confirmar',
|
||||
cancel: 'Cancelar'
|
||||
},
|
||||
},
|
||||
virtualDevice: {
|
||||
network: 'Red Virtual',
|
||||
disk: 'Disco Virtual'
|
||||
@@ -155,14 +168,16 @@ const en = {
|
||||
install: 'Instalar',
|
||||
installing: 'Instalando',
|
||||
failed: 'La instalación falló',
|
||||
retry: 'Por favor, actualiza la página e inténtalo de nuevo. O intenta instalarlo manualmente',
|
||||
retry:
|
||||
'Por favor, actualiza la página e inténtalo de nuevo. O intenta instalarlo manualmente',
|
||||
download: 'Descargar el',
|
||||
package: 'paquete de instalación',
|
||||
unzip: 'y descomprimirlo',
|
||||
upTailscale: 'Sube tailscale al directorio /usr/bin/ del NanoKVM',
|
||||
upTailscaled: 'Sube tailscaled al directorio /usr/sbin/ del NanoKVM',
|
||||
refresh: 'Actualizar la página actual',
|
||||
notLogin: 'El dispositivo aún no ha sido vinculado. Por favor, inicia sesión y vincula este dispositivo a tu cuenta.',
|
||||
notLogin:
|
||||
'El dispositivo aún no ha sido vinculado. Por favor, inicia sesión y vincula este dispositivo a tu cuenta.',
|
||||
urlPeriod: 'Esta URL es válida por 10 minutos',
|
||||
login: 'Iniciar sesión',
|
||||
loginSuccess: 'Inicio de sesión exitoso',
|
||||
|
||||
@@ -34,6 +34,8 @@ const fr = {
|
||||
invalidUser: "Nom d'utilisateur ou mot de passe invalide",
|
||||
error: 'Erreur inattendue',
|
||||
changePassword: 'Changer le mot de passe',
|
||||
changePasswordDesc:
|
||||
'Pour la sécurité de votre appareil, veuillez modifier le mot de passe de connexion Web.',
|
||||
differentPassword: 'Les mots de passe ne correspondent pas',
|
||||
illegalUsername: "Le nom d'utilisateur contient des caractères illégaux",
|
||||
illegalPassword: 'Le mot de passe contient des caractères illégaux',
|
||||
@@ -48,6 +50,7 @@ const fr = {
|
||||
cancel: 'Annuler'
|
||||
},
|
||||
screen: {
|
||||
video: 'Mode vidéo',
|
||||
resolution: 'Résolution',
|
||||
auto: 'Automatique',
|
||||
autoTips:
|
||||
@@ -55,6 +58,10 @@ const fr = {
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Personnaliser',
|
||||
quality: 'Qualité',
|
||||
qualityLossless: 'Sans perte',
|
||||
qualityHigh: 'Élevé',
|
||||
qualityMedium: 'Moyen',
|
||||
qualityLow: 'Bas',
|
||||
frameDetect: 'Frame Detect',
|
||||
frameDetectTip:
|
||||
"Calcule la différence entre les images. Arrête la transmission du flux vidéo lorsqu'aucun changement n'est détecté sur l'écran de l'hôte distant"
|
||||
|
||||
@@ -1,182 +1,193 @@
|
||||
const hu = {
|
||||
translation: {
|
||||
language: 'Nyelv',
|
||||
translation: {
|
||||
language: 'Nyelv',
|
||||
changePassword: 'Jelszó megváltoztatása',
|
||||
logout: 'Kijelentkezés',
|
||||
settings: 'Beállítások',
|
||||
showMouse: 'Egér mutatása',
|
||||
hideMouse: 'Egér elrejtése',
|
||||
power: 'Bekapcsolás',
|
||||
reset: 'Újraindítás',
|
||||
powerShort: 'Bekapcsolás (rövid kattintás)',
|
||||
powerLong: 'Bekapcsolás (hosszú kattintás)',
|
||||
hddLed: 'HDD LED',
|
||||
checkLibFailed: 'Nem sikerült ellenőrizni a futási könyvtárat, próbálja újra',
|
||||
updateLibFailed: 'Nem sikerült frissíteni a futási könyvtárat, próbálja újra',
|
||||
updatingLib:
|
||||
'A futási könyvtár frissítése folyamatban. Kérem, frissítse az oldalt a frissítés után.',
|
||||
checkForUpdate: 'Frissítés keresése',
|
||||
head: {
|
||||
desktop: 'Távoli Asztal',
|
||||
login: 'Bejelentkezés',
|
||||
changePassword: 'Jelszó megváltoztatása',
|
||||
logout: 'Kijelentkezés',
|
||||
settings: 'Beállítások',
|
||||
showMouse: 'Egér mutatása',
|
||||
hideMouse: 'Egér elrejtése',
|
||||
power: 'Bekapcsolás',
|
||||
reset: 'Újraindítás',
|
||||
powerShort: 'Bekapcsolás (rövid kattintás)',
|
||||
powerLong: 'Bekapcsolás (hosszú kattintás)',
|
||||
hddLed: 'HDD LED',
|
||||
checkLibFailed: 'Nem sikerült ellenőrizni a futási könyvtárat, próbálja újra',
|
||||
updateLibFailed: 'Nem sikerült frissíteni a futási könyvtárat, próbálja újra',
|
||||
updatingLib: 'A futási könyvtár frissítése folyamatban. Kérem, frissítse az oldalt a frissítés után.',
|
||||
checkForUpdate: 'Frissítés keresése',
|
||||
head: {
|
||||
desktop: 'Távoli Asztal',
|
||||
login: 'Bejelentkezés',
|
||||
changePassword: 'Jelszó megváltoztatása',
|
||||
terminal: 'Terminál'
|
||||
},
|
||||
auth: {
|
||||
login: 'Bejelentkezés',
|
||||
placeholderUsername: 'Adja meg a felhasználónevet',
|
||||
placeholderPassword: 'Adja meg a jelszót',
|
||||
placeholderPassword2: 'Adja meg újra a jelszót',
|
||||
noEmptyUsername: 'A felhasználónév nem lehet üres',
|
||||
noEmptyPassword: 'A jelszó nem lehet üres',
|
||||
noAccount: 'Nem sikerült megszerezni a felhasználói információkat, frissítse az oldalt vagy állítsa vissza a jelszót',
|
||||
invalidUser: 'Érvénytelen felhasználónév vagy jelszó',
|
||||
error: 'Váratlan hiba',
|
||||
changePassword: 'Jelszó megváltoztatása',
|
||||
differentPassword: 'A jelszavak nem egyeznek',
|
||||
illegalUsername: 'A felhasználónév illegális karaktereket tartalmaz',
|
||||
illegalPassword: 'A jelszó illegális karaktereket tartalmaz',
|
||||
forgetPassword: 'Jelszó-emlékeztető',
|
||||
resetPassword: 'Jelszó alaphelyzetbe állítása',
|
||||
reset1: 'Ha elfelejtette a jelszót, kövesse az alábbi lépéseket:',
|
||||
reset2: '1. Jelentkezzen be a NanoKVM eszközre SSH-n keresztül;',
|
||||
reset3: '2. Törölje a fájlt az eszközről:',
|
||||
reset4: '3. Használja az alapértelmezett fiókot a bejelentkezéshez:',
|
||||
ok: 'Ok',
|
||||
cancel: 'Mégse'
|
||||
},
|
||||
screen: {
|
||||
resolution: 'Felbontás',
|
||||
auto: 'Automatikus',
|
||||
autoTips:
|
||||
'Bizonyos felbontások esetén képernyőszakadás vagy egéreltolódás léphet fel. Fontolja meg a távoli gép felbontásának módosítását vagy az automatikus mód kikapcsolását.',
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Testreszabás',
|
||||
quality: 'Minőség',
|
||||
frameDetect: 'Képkocka-figyelés',
|
||||
frameDetectTip:
|
||||
'Elemzi a képkockák közötti különbségeket. A videó stream küldése leáll, ha a távoli gép képernyőjén nem történik változás.'
|
||||
},
|
||||
keyboard: {
|
||||
paste: 'Beillesztés',
|
||||
tips: 'Csak a szabványos billentyűzet betűi és szimbólumai támogatottak',
|
||||
placeholder: 'Írja be',
|
||||
submit: 'Elküldés',
|
||||
virtual: 'Billentyűzet'
|
||||
},
|
||||
mouse: {
|
||||
default: 'Alapértelmezett kurzor',
|
||||
pointer: 'Mutató kurzor',
|
||||
cell: 'Cella kurzor',
|
||||
text: 'Szöveg kurzor',
|
||||
grab: 'Markoló kurzor',
|
||||
hide: 'Kurzor elrejtése',
|
||||
mode: 'Egér mód',
|
||||
absolute: 'Abszolút mód',
|
||||
relative: 'Relatív mód',
|
||||
requestPointer: 'Relatív mód használata. Kattintson az asztalra, hogy megjelenjen az egérmutató.',
|
||||
resetHid: 'HID alaphelyzetbe állítása'
|
||||
},
|
||||
image: {
|
||||
title: 'Képek',
|
||||
loading: 'Betöltés...',
|
||||
empty: 'Nem található semmi',
|
||||
mountFailed: 'Csatlakoztatás sikertelen',
|
||||
mountDesc:
|
||||
'Egyes rendszerekben szükséges lehet a virtuális lemez eltávolítása a távoli gépen, mielőtt a képet csatlakoztatja.',
|
||||
tips: {
|
||||
title: 'Hogyan tölts fel képeket',
|
||||
usb1: 'Csatlakoztassa a NanoKVM-t a számítógépéhez USB-n keresztül.',
|
||||
usb2: 'Győződjön meg róla, hogy a virtuális lemez csatlakoztatva van (Beállítások - Virtuális lemez).',
|
||||
usb3: 'Nyissa meg a virtuális lemezt a számítógépén, és másolja a kép fájlt a virtuális lemez gyökérkönyvtárába.',
|
||||
scp1: 'Győződjön meg róla, hogy a NanoKVM és a számítógépe ugyanazon a helyi hálózaton van.',
|
||||
scp2: 'Nyisson meg egy terminált a számítógépén, és használja az SCP parancsot a kép fájl feltöltésére a /data könyvtárba a NanoKVM-en.',
|
||||
scp3: 'Példa: scp your-image-path root@your-nanokvm-ip:/data',
|
||||
tfCard: 'TF Kártya',
|
||||
tf1: 'Ez a módszer támogatott Linux rendszeren',
|
||||
tf2: 'Vegye ki a TF kártyát a NanoKVM-ből (a TELJES verzióhoz, először szedje szét a házat).',
|
||||
tf3: 'Helyezze a TF kártyát egy kártyaolvasóba, és csatlakoztassa a számítógépéhez.',
|
||||
tf4: 'Másolja a képfájlt a TF kártya /data könyvtárába.',
|
||||
tf5: 'Helyezze vissza a TF kártyát a NanoKVM-be.'
|
||||
}
|
||||
},
|
||||
script: {
|
||||
title: 'Szkriptek',
|
||||
upload: 'Feltöltés',
|
||||
run: 'Futtatás',
|
||||
runBackground: 'Háttérben futtatás',
|
||||
runFailed: 'Futtatás sikertelen',
|
||||
attention: 'Figyelem',
|
||||
delDesc: 'Biztosan törli ezt a fájlt?',
|
||||
confirm: 'Igen',
|
||||
cancel: 'Nem',
|
||||
delete: 'Törlés',
|
||||
close: 'Bezárás'
|
||||
},
|
||||
terminal: {
|
||||
title: 'Terminál',
|
||||
nanokvm: 'NanoKVM Terminál',
|
||||
serial: 'Soros port terminál',
|
||||
serialPort: 'Soros port',
|
||||
serialPortPlaceholder: 'Adja meg a soros portot',
|
||||
baudrate: 'Baudráta',
|
||||
confirm: 'Ok'
|
||||
},
|
||||
wol: {
|
||||
sending: 'Parancs küldése...',
|
||||
sent: 'Parancs elküldve',
|
||||
input: 'Adja meg a MAC címet',
|
||||
ok: 'Ok'
|
||||
},
|
||||
about: {
|
||||
title: 'NanoKVM Névjegy',
|
||||
information: 'Információ',
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
application: 'Alkalmazás verzió',
|
||||
image: 'Képfájl verzió',
|
||||
deviceKey: 'Eszköz kulcs',
|
||||
queryFailed: 'Lekérdezés sikertelen',
|
||||
community: 'Közösség'
|
||||
},
|
||||
update: {
|
||||
title: 'Frissítés keresése',
|
||||
queryFailed: 'Verzió lekérdezése sikertelen',
|
||||
updateFailed: 'Frissítés sikertelen. Kérem, próbálja újra.',
|
||||
isLatest: 'Ön már a legfrissebb verziót használja.',
|
||||
available: 'Frissítés elérhető. Biztos, hogy frissít?',
|
||||
updating: 'Frissítés elkezdődött. Kérem várjon...',
|
||||
confirm: 'Megerősítés',
|
||||
cancel: 'Mégse'
|
||||
},
|
||||
virtualDevice: {
|
||||
network: 'Virtuális hálózat',
|
||||
disk: 'Virtuális lemez'
|
||||
},
|
||||
tailscale: {
|
||||
loading: 'Betöltés...',
|
||||
notInstall: 'Tailscale nem található! Kérem, telepítse.',
|
||||
install: 'Telepítés',
|
||||
installing: 'Telepítés folyamatban',
|
||||
failed: 'Telepítés sikertelen',
|
||||
retry: 'Frissítse az oldalt, majd próbálja újra. Vagy próbálja meg manuálisan telepíteni.',
|
||||
download: 'Letöltés a',
|
||||
package: 'telepítési csomag',
|
||||
unzip: 'és kicsomagolás',
|
||||
upTailscale: 'Töltsön fel tailscale-t a NanoKVM /usr/bin/ könyvtárába',
|
||||
upTailscaled: 'Töltsön fel tailscaled-t a NanoKVM /usr/sbin/ könyvtárába',
|
||||
refresh: 'Frissítse az aktuális oldalt',
|
||||
notLogin: 'Az eszköz még nincs kötve. Kérem, jelentkezzen be és kösse az eszközt a fiókjához.',
|
||||
urlPeriod: 'Ez az url 10 percig érvényes',
|
||||
login: 'Bejelentkezés',
|
||||
loginSuccess: 'Sikeres bejelentkezés',
|
||||
enable: 'Tailscale engedélyezése',
|
||||
deviceName: 'Eszköz neve',
|
||||
deviceIP: 'Eszköz IP',
|
||||
account: 'Fiók',
|
||||
logout: 'Kijelentkezés',
|
||||
logout2: 'Biztos, hogy kijelentkezik?'
|
||||
terminal: 'Terminál'
|
||||
},
|
||||
auth: {
|
||||
login: 'Bejelentkezés',
|
||||
placeholderUsername: 'Adja meg a felhasználónevet',
|
||||
placeholderPassword: 'Adja meg a jelszót',
|
||||
placeholderPassword2: 'Adja meg újra a jelszót',
|
||||
noEmptyUsername: 'A felhasználónév nem lehet üres',
|
||||
noEmptyPassword: 'A jelszó nem lehet üres',
|
||||
noAccount:
|
||||
'Nem sikerült megszerezni a felhasználói információkat, frissítse az oldalt vagy állítsa vissza a jelszót',
|
||||
invalidUser: 'Érvénytelen felhasználónév vagy jelszó',
|
||||
error: 'Váratlan hiba',
|
||||
changePassword: 'Jelszó megváltoztatása',
|
||||
changePasswordDesc:
|
||||
'Az eszköz biztonsága érdekében módosítsa a webes bejelentkezési jelszót.',
|
||||
differentPassword: 'A jelszavak nem egyeznek',
|
||||
illegalUsername: 'A felhasználónév illegális karaktereket tartalmaz',
|
||||
illegalPassword: 'A jelszó illegális karaktereket tartalmaz',
|
||||
forgetPassword: 'Jelszó-emlékeztető',
|
||||
resetPassword: 'Jelszó alaphelyzetbe állítása',
|
||||
reset1: 'Ha elfelejtette a jelszót, kövesse az alábbi lépéseket:',
|
||||
reset2: '1. Jelentkezzen be a NanoKVM eszközre SSH-n keresztül;',
|
||||
reset3: '2. Törölje a fájlt az eszközről:',
|
||||
reset4: '3. Használja az alapértelmezett fiókot a bejelentkezéshez:',
|
||||
ok: 'Ok',
|
||||
cancel: 'Mégse'
|
||||
},
|
||||
screen: {
|
||||
video: 'Videó mód',
|
||||
resolution: 'Felbontás',
|
||||
auto: 'Automatikus',
|
||||
autoTips:
|
||||
'Bizonyos felbontások esetén képernyőszakadás vagy egéreltolódás léphet fel. Fontolja meg a távoli gép felbontásának módosítását vagy az automatikus mód kikapcsolását.',
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Testreszabás',
|
||||
quality: 'Minőség',
|
||||
qualityLossless: 'Veszteségmentes',
|
||||
qualityHigh: 'Magas',
|
||||
qualityMedium: 'Közepes',
|
||||
qualityLow: 'Alacsony',
|
||||
frameDetect: 'Képkocka-figyelés',
|
||||
frameDetectTip:
|
||||
'Elemzi a képkockák közötti különbségeket. A videó stream küldése leáll, ha a távoli gép képernyőjén nem történik változás.'
|
||||
},
|
||||
keyboard: {
|
||||
paste: 'Beillesztés',
|
||||
tips: 'Csak a szabványos billentyűzet betűi és szimbólumai támogatottak',
|
||||
placeholder: 'Írja be',
|
||||
submit: 'Elküldés',
|
||||
virtual: 'Billentyűzet'
|
||||
},
|
||||
mouse: {
|
||||
default: 'Alapértelmezett kurzor',
|
||||
pointer: 'Mutató kurzor',
|
||||
cell: 'Cella kurzor',
|
||||
text: 'Szöveg kurzor',
|
||||
grab: 'Markoló kurzor',
|
||||
hide: 'Kurzor elrejtése',
|
||||
mode: 'Egér mód',
|
||||
absolute: 'Abszolút mód',
|
||||
relative: 'Relatív mód',
|
||||
requestPointer:
|
||||
'Relatív mód használata. Kattintson az asztalra, hogy megjelenjen az egérmutató.',
|
||||
resetHid: 'HID alaphelyzetbe állítása'
|
||||
},
|
||||
image: {
|
||||
title: 'Képek',
|
||||
loading: 'Betöltés...',
|
||||
empty: 'Nem található semmi',
|
||||
mountFailed: 'Csatlakoztatás sikertelen',
|
||||
mountDesc:
|
||||
'Egyes rendszerekben szükséges lehet a virtuális lemez eltávolítása a távoli gépen, mielőtt a képet csatlakoztatja.',
|
||||
tips: {
|
||||
title: 'Hogyan tölts fel képeket',
|
||||
usb1: 'Csatlakoztassa a NanoKVM-t a számítógépéhez USB-n keresztül.',
|
||||
usb2: 'Győződjön meg róla, hogy a virtuális lemez csatlakoztatva van (Beállítások - Virtuális lemez).',
|
||||
usb3: 'Nyissa meg a virtuális lemezt a számítógépén, és másolja a kép fájlt a virtuális lemez gyökérkönyvtárába.',
|
||||
scp1: 'Győződjön meg róla, hogy a NanoKVM és a számítógépe ugyanazon a helyi hálózaton van.',
|
||||
scp2: 'Nyisson meg egy terminált a számítógépén, és használja az SCP parancsot a kép fájl feltöltésére a /data könyvtárba a NanoKVM-en.',
|
||||
scp3: 'Példa: scp your-image-path root@your-nanokvm-ip:/data',
|
||||
tfCard: 'TF Kártya',
|
||||
tf1: 'Ez a módszer támogatott Linux rendszeren',
|
||||
tf2: 'Vegye ki a TF kártyát a NanoKVM-ből (a TELJES verzióhoz, először szedje szét a házat).',
|
||||
tf3: 'Helyezze a TF kártyát egy kártyaolvasóba, és csatlakoztassa a számítógépéhez.',
|
||||
tf4: 'Másolja a képfájlt a TF kártya /data könyvtárába.',
|
||||
tf5: 'Helyezze vissza a TF kártyát a NanoKVM-be.'
|
||||
}
|
||||
},
|
||||
script: {
|
||||
title: 'Szkriptek',
|
||||
upload: 'Feltöltés',
|
||||
run: 'Futtatás',
|
||||
runBackground: 'Háttérben futtatás',
|
||||
runFailed: 'Futtatás sikertelen',
|
||||
attention: 'Figyelem',
|
||||
delDesc: 'Biztosan törli ezt a fájlt?',
|
||||
confirm: 'Igen',
|
||||
cancel: 'Nem',
|
||||
delete: 'Törlés',
|
||||
close: 'Bezárás'
|
||||
},
|
||||
terminal: {
|
||||
title: 'Terminál',
|
||||
nanokvm: 'NanoKVM Terminál',
|
||||
serial: 'Soros port terminál',
|
||||
serialPort: 'Soros port',
|
||||
serialPortPlaceholder: 'Adja meg a soros portot',
|
||||
baudrate: 'Baudráta',
|
||||
confirm: 'Ok'
|
||||
},
|
||||
wol: {
|
||||
sending: 'Parancs küldése...',
|
||||
sent: 'Parancs elküldve',
|
||||
input: 'Adja meg a MAC címet',
|
||||
ok: 'Ok'
|
||||
},
|
||||
about: {
|
||||
title: 'NanoKVM Névjegy',
|
||||
information: 'Információ',
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
application: 'Alkalmazás verzió',
|
||||
image: 'Képfájl verzió',
|
||||
deviceKey: 'Eszköz kulcs',
|
||||
queryFailed: 'Lekérdezés sikertelen',
|
||||
community: 'Közösség'
|
||||
},
|
||||
update: {
|
||||
title: 'Frissítés keresése',
|
||||
queryFailed: 'Verzió lekérdezése sikertelen',
|
||||
updateFailed: 'Frissítés sikertelen. Kérem, próbálja újra.',
|
||||
isLatest: 'Ön már a legfrissebb verziót használja.',
|
||||
available: 'Frissítés elérhető. Biztos, hogy frissít?',
|
||||
updating: 'Frissítés elkezdődött. Kérem várjon...',
|
||||
confirm: 'Megerősítés',
|
||||
cancel: 'Mégse'
|
||||
},
|
||||
virtualDevice: {
|
||||
network: 'Virtuális hálózat',
|
||||
disk: 'Virtuális lemez'
|
||||
},
|
||||
tailscale: {
|
||||
loading: 'Betöltés...',
|
||||
notInstall: 'Tailscale nem található! Kérem, telepítse.',
|
||||
install: 'Telepítés',
|
||||
installing: 'Telepítés folyamatban',
|
||||
failed: 'Telepítés sikertelen',
|
||||
retry: 'Frissítse az oldalt, majd próbálja újra. Vagy próbálja meg manuálisan telepíteni.',
|
||||
download: 'Letöltés a',
|
||||
package: 'telepítési csomag',
|
||||
unzip: 'és kicsomagolás',
|
||||
upTailscale: 'Töltsön fel tailscale-t a NanoKVM /usr/bin/ könyvtárába',
|
||||
upTailscaled: 'Töltsön fel tailscaled-t a NanoKVM /usr/sbin/ könyvtárába',
|
||||
refresh: 'Frissítse az aktuális oldalt',
|
||||
notLogin:
|
||||
'Az eszköz még nincs kötve. Kérem, jelentkezzen be és kösse az eszközt a fiókjához.',
|
||||
urlPeriod: 'Ez az url 10 percig érvényes',
|
||||
login: 'Bejelentkezés',
|
||||
loginSuccess: 'Sikeres bejelentkezés',
|
||||
enable: 'Tailscale engedélyezése',
|
||||
deviceName: 'Eszköz neve',
|
||||
deviceIP: 'Eszköz IP',
|
||||
account: 'Fiók',
|
||||
logout: 'Kijelentkezés',
|
||||
logout2: 'Biztos, hogy kijelentkezik?'
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default hu;
|
||||
export default hu;
|
||||
|
||||
@@ -28,10 +28,12 @@ const id = {
|
||||
placeholderPassword2: 'Silahkan masukkan password again',
|
||||
noEmptyUsername: 'nama user tidak boleh kosong',
|
||||
noEmptyPassword: 'sandi tidak boleh kosong',
|
||||
noAccount: 'Gagal mendapatkan informasi user, silahkan segarkan halaman atau atur ulang sandi',
|
||||
noAccount:
|
||||
'Gagal mendapatkan informasi user, silahkan segarkan halaman atau atur ulang sandi',
|
||||
invalidUser: 'invalid username or password',
|
||||
error: 'terjadi kesalahan tak terduga',
|
||||
changePassword: 'Ganti Sandi',
|
||||
changePasswordDesc: 'Untuk keamanan perangkat Anda, silakan ubah kata sandi masuk web.',
|
||||
differentPassword: 'sandi tidak sesuai',
|
||||
illegalUsername: 'ada karakter ilegal pada nama user',
|
||||
illegalPassword: 'ada karakter ilegal pada sandi',
|
||||
@@ -45,16 +47,21 @@ const id = {
|
||||
cancel: 'Batalkan'
|
||||
},
|
||||
screen: {
|
||||
video: 'Mode Video',
|
||||
resolution: 'Resolusi',
|
||||
auto: 'Otomatis',
|
||||
autoTips:
|
||||
"Tearing layar atau offset tetikus dapat terjadi pada resolusi tertentu. Pertimbangkan untuk menyesuaikan resolusi host jarak jauh atau menonaktifkan mode otomatis.",
|
||||
'Tearing layar atau offset tetikus dapat terjadi pada resolusi tertentu. Pertimbangkan untuk menyesuaikan resolusi host jarak jauh atau menonaktifkan mode otomatis.',
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Sesuaikan',
|
||||
quality: 'Kualitas',
|
||||
qualityLossless: 'Tanpa Kehilangan',
|
||||
qualityHigh: 'Tinggi',
|
||||
qualityMedium: 'Sedang',
|
||||
qualityLow: 'Rendah',
|
||||
frameDetect: 'Deteksi bingkai',
|
||||
frameDetectTip:
|
||||
"Hitung selisih antar frame. Hentikan transmisi aliran video saat tidak ada perubahan yang terdeteksi di layar host jarak jauh."
|
||||
'Hitung selisih antar frame. Hentikan transmisi aliran video saat tidak ada perubahan yang terdeteksi di layar host jarak jauh.'
|
||||
},
|
||||
keyboard: {
|
||||
paste: 'Tempel',
|
||||
@@ -73,7 +80,8 @@ const id = {
|
||||
mode: 'Mode tetikus',
|
||||
absolute: 'Mode absolut',
|
||||
relative: 'Mode relatif',
|
||||
requestPointer: 'Menggunakan mode relatf. Silakan klik desktop untuk mendapatkan penunjuk tetikus.',
|
||||
requestPointer:
|
||||
'Menggunakan mode relatf. Silakan klik desktop untuk mendapatkan penunjuk tetikus.',
|
||||
resetHid: 'Setel ulang HID'
|
||||
},
|
||||
image: {
|
||||
@@ -82,7 +90,7 @@ const id = {
|
||||
empty: 'Tidak ada yang ditemukan',
|
||||
mountFailed: 'Pemasangan Gagal',
|
||||
mountDesc:
|
||||
"Di beberapa sistem, perlu mengeluarkan disk virtual pada host jarak jauh sebelum memasang gambar.",
|
||||
'Di beberapa sistem, perlu mengeluarkan disk virtual pada host jarak jauh sebelum memasang gambar.',
|
||||
tips: {
|
||||
title: 'Cara mengunggah',
|
||||
usb1: 'Hubungkan NanoKVM ke komputer Anda melalui USB.',
|
||||
@@ -165,8 +173,7 @@ const id = {
|
||||
upTailscale: 'Unggah tailscale ke direktori NanoKVM /usr/bin/',
|
||||
upTailscaled: 'Unggah tailscaled ke direktori NanoKVM /usr/sbin/',
|
||||
refresh: 'Segarkan halaman ini',
|
||||
notLogin:
|
||||
'Perangkat belum ditautkan. Silakan masuk dan tautkan perangkat ini ke akun Anda.',
|
||||
notLogin: 'Perangkat belum ditautkan. Silakan masuk dan tautkan perangkat ini ke akun Anda.',
|
||||
urlPeriod: 'Url ini berlaku selama 10 menit',
|
||||
login: 'Masuk',
|
||||
loginSuccess: 'Berhasil masuk',
|
||||
|
||||
@@ -33,6 +33,8 @@ const it = {
|
||||
invalidUser: 'Nome utente o password non validi',
|
||||
error: 'Errore imprevisto',
|
||||
changePassword: 'Cambia Password',
|
||||
changePasswordDesc:
|
||||
'Per la sicurezza del tuo dispositivo, modifica la password di accesso web.',
|
||||
differentPassword: 'Le password non corrispondono',
|
||||
illegalUsername: 'Il nome utente contiene caratteri non validi',
|
||||
illegalPassword: 'La password contiene caratteri non validi',
|
||||
@@ -46,6 +48,7 @@ const it = {
|
||||
cancel: 'Annulla'
|
||||
},
|
||||
screen: {
|
||||
video: 'Modalità video',
|
||||
resolution: 'Risoluzione',
|
||||
auto: 'Automatico',
|
||||
autoTips:
|
||||
@@ -53,6 +56,10 @@ const it = {
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Personalizza',
|
||||
quality: 'Qualità',
|
||||
qualityLossless: 'Senza perdita',
|
||||
qualityHigh: 'Alto',
|
||||
qualityMedium: 'Medio',
|
||||
qualityLow: 'Basso',
|
||||
frameDetect: 'Rilevamento Frame',
|
||||
frameDetectTip:
|
||||
'Calcola la differenza tra i frame. Interrompe la trasmissione del flusso video quando non vengono rilevate modifiche sullo schermo del dispositivo remoto.'
|
||||
|
||||
@@ -28,10 +28,13 @@ const ja = {
|
||||
placeholderPassword2: '再度パスワードを入力してください',
|
||||
noEmptyUsername: 'ユーザー名は空にできません',
|
||||
noEmptyPassword: 'パスワードは空にできません',
|
||||
noAccount: 'ユーザー情報の取得に失敗しました。ウェブページをリフレッシュするか、パスワードをリセットしてください。',
|
||||
noAccount:
|
||||
'ユーザー情報の取得に失敗しました。ウェブページをリフレッシュするか、パスワードをリセットしてください。',
|
||||
invalidUser: '無効なユーザー名またはパスワード',
|
||||
error: '予期しないエラー',
|
||||
changePassword: 'パスワード変更',
|
||||
changePasswordDesc:
|
||||
'デバイスのセキュリティのために、ウェブログインのパスワードを変更してください。',
|
||||
differentPassword: 'パスワードが一致しません',
|
||||
illegalUsername: 'ユーザー名に不正な文字が含まれています',
|
||||
illegalPassword: 'パスワードに不正な文字が含まれています',
|
||||
@@ -45,16 +48,21 @@ const ja = {
|
||||
cancel: 'キャンセル'
|
||||
},
|
||||
screen: {
|
||||
video: 'ビデオモード',
|
||||
resolution: '解像度',
|
||||
auto: '自動',
|
||||
autoTips:
|
||||
"特定の解像度で画面のティアリングやマウスのオフセットが発生する可能性があります。リモートホストの解像度を調整するか、自動モードを無効にすることを検討してください。",
|
||||
'特定の解像度で画面のティアリングやマウスのオフセットが発生する可能性があります。リモートホストの解像度を調整するか、自動モードを無効にすることを検討してください。',
|
||||
fps: 'FPS',
|
||||
customizeFps: 'カスタマイズ',
|
||||
quality: '品質',
|
||||
qualityLossless: 'ロスレス',
|
||||
qualityHigh: '高い',
|
||||
qualityMedium: '中くらい',
|
||||
qualityLow: '低い',
|
||||
frameDetect: 'フレーム検出',
|
||||
frameDetectTip:
|
||||
"フレーム間の差を計算します。リモートホストの画面で変更が検出されない場合、ビデオストリームの送信を停止します。"
|
||||
'フレーム間の差を計算します。リモートホストの画面で変更が検出されない場合、ビデオストリームの送信を停止します。'
|
||||
},
|
||||
keyboard: {
|
||||
paste: '貼り付け',
|
||||
@@ -73,7 +81,8 @@ const ja = {
|
||||
mode: 'マウスモード',
|
||||
absolute: '絶対モード',
|
||||
relative: '相対モード',
|
||||
requestPointer: '相対モードを使用中です。デスクトップをクリックしてマウスポインタを取得してください。',
|
||||
requestPointer:
|
||||
'相対モードを使用中です。デスクトップをクリックしてマウスポインタを取得してください。',
|
||||
resetHid: 'HIDをリセット'
|
||||
},
|
||||
image: {
|
||||
@@ -82,7 +91,7 @@ const ja = {
|
||||
empty: '見つかりませんでした',
|
||||
mountFailed: 'マウントに失敗しました',
|
||||
mountDesc:
|
||||
"一部のシステムでは、イメージをマウントする前にリモートホストで仮想ディスクをアンマウントする必要があります。",
|
||||
'一部のシステムでは、イメージをマウントする前にリモートホストで仮想ディスクをアンマウントする必要があります。',
|
||||
tips: {
|
||||
title: 'アップロード方法',
|
||||
usb1: 'NanoKVMをUSB経由でコンピュータに接続します。',
|
||||
|
||||
@@ -33,6 +33,7 @@ const ko = {
|
||||
invalidUser: '유저 이름이나 비밀번호가 틀렸습니다.',
|
||||
error: '정의되지 않은 에러',
|
||||
changePassword: '비밀번호 변경',
|
||||
changePasswordDesc: '기기의 보안을 위해 웹 로그인 비밀번호를 수정해 주세요.',
|
||||
differentPassword: '두 비밀번호가 서로 상이합니다.',
|
||||
illegalUsername: '유저 이름에 사용할 수 없는 문자가 있습니다.',
|
||||
illegalPassword: '비밀번호에 사용할 수 없는 문자가 있습니다.',
|
||||
@@ -46,6 +47,7 @@ const ko = {
|
||||
cancel: '취소'
|
||||
},
|
||||
screen: {
|
||||
video: '비디오 모드',
|
||||
resolution: '해상도',
|
||||
auto: '오토매틱',
|
||||
autoTips:
|
||||
@@ -53,6 +55,10 @@ const ko = {
|
||||
fps: 'FPS',
|
||||
customizeFps: 'FPS 설정',
|
||||
quality: '품질',
|
||||
qualityLossless: '무손실',
|
||||
qualityHigh: '높음',
|
||||
qualityMedium: '중간',
|
||||
qualityLow: '낮음',
|
||||
frameDetect: '프레임 탐지',
|
||||
frameDetectTip:
|
||||
'프레임 간의 차이를 계산합니다. 원격 호스트 화면에 변경 사항이 감지되지 않으면 비디오 스트림 전송을 중지합니다.'
|
||||
|
||||
@@ -28,10 +28,13 @@ const nl = {
|
||||
placeholderPassword2: 'Voer wachtwoord nogmaals in',
|
||||
noEmptyUsername: 'Gebruikersnaam mag niet leeg zijn',
|
||||
noEmptyPassword: 'Wachtwoord mag niet leeg zijn',
|
||||
noAccount: 'Ophalen van gebruikersinformatie mislukt, vernieuw de webpagina of reset het wachtwoord',
|
||||
noAccount:
|
||||
'Ophalen van gebruikersinformatie mislukt, vernieuw de webpagina of reset het wachtwoord',
|
||||
invalidUser: 'Ongeldige gebruikersnaam of wachtwoord',
|
||||
error: 'Onverwachte fout',
|
||||
changePassword: 'Wachtwoord wijzigen',
|
||||
changePasswordDesc:
|
||||
'Voor de veiligheid van uw apparaat, wijzig alstublieft het webaanmeldingswachtwoord.',
|
||||
differentPassword: 'Wachtwoorden komen niet overeen',
|
||||
illegalUsername: 'Gebruikersnaam bevat ongeldige tekens',
|
||||
illegalPassword: 'Wachtwoord bevat ongeldige tekens',
|
||||
@@ -45,6 +48,7 @@ const nl = {
|
||||
cancel: 'Annuleren'
|
||||
},
|
||||
screen: {
|
||||
video: 'Videomodus',
|
||||
resolution: 'Resolutie',
|
||||
auto: 'Automatisch',
|
||||
autoTips:
|
||||
@@ -52,6 +56,10 @@ const nl = {
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Aanpassen',
|
||||
quality: 'Kwaliteit',
|
||||
qualityLossless: 'Verliesvrij',
|
||||
qualityHigh: 'Hoog',
|
||||
qualityMedium: 'Gemiddeld',
|
||||
qualityLow: 'Laag',
|
||||
frameDetect: 'Frame detectie',
|
||||
frameDetectTip:
|
||||
'Berekent het verschil tussen frames. Stopt met het verzenden van de videostream wanneer er geen veranderingen worden gedetecteerd op het scherm van de externe host.'
|
||||
@@ -73,7 +81,8 @@ const nl = {
|
||||
mode: 'Muismodus',
|
||||
absolute: 'Absolute modus',
|
||||
relative: 'Relatieve modus',
|
||||
requestPointer: 'Relatieve modus wordt gebruikt. Klik op het bureaublad om de muisaanwijzer te krijgen.',
|
||||
requestPointer:
|
||||
'Relatieve modus wordt gebruikt. Klik op het bureaublad om de muisaanwijzer te krijgen.',
|
||||
resetHid: 'HID resetten'
|
||||
},
|
||||
image: {
|
||||
@@ -165,8 +174,7 @@ const nl = {
|
||||
upTailscale: 'Upload tailscale naar NanoKVM directory /usr/bin/',
|
||||
upTailscaled: 'Upload tailscaled naar NanoKVM directory /usr/sbin/',
|
||||
refresh: 'Vernieuw huidige pagina',
|
||||
notLogin:
|
||||
'Het apparaat is nog niet gekoppeld. Log in en koppel dit apparaat aan uw account.',
|
||||
notLogin: 'Het apparaat is nog niet gekoppeld. Log in en koppel dit apparaat aan uw account.',
|
||||
urlPeriod: 'Deze url is 10 minuten geldig',
|
||||
login: 'Inloggen',
|
||||
loginSuccess: 'Inloggen gelukt',
|
||||
|
||||
@@ -33,6 +33,8 @@ const pl = {
|
||||
invalidUser: 'Błędne hasło lub nazwa użykownika',
|
||||
error: 'niespodziewany błąd',
|
||||
changePassword: 'Zmień Hasło',
|
||||
changePasswordDesc:
|
||||
'Dla bezpieczeństwa Twojego urządzenia, proszę zmień hasło do logowania w sieci.',
|
||||
differentPassword: 'hasła nie zgadzają się',
|
||||
illegalUsername: 'nazwa użytkownika zawiera niedozwolone znaki',
|
||||
illegalPassword: 'hasło zawiera niedozwolone znaki',
|
||||
@@ -46,6 +48,7 @@ const pl = {
|
||||
cancel: 'Anuluj'
|
||||
},
|
||||
screen: {
|
||||
video: 'Tryb wideo',
|
||||
resolution: 'Rozdzielczość',
|
||||
auto: 'Automatyczny',
|
||||
autoTips:
|
||||
@@ -53,6 +56,10 @@ const pl = {
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Personalizuj',
|
||||
quality: 'Jakość',
|
||||
qualityLossless: 'Bezstratny',
|
||||
qualityHigh: 'Wysoki',
|
||||
qualityMedium: 'Średni',
|
||||
qualityLow: 'Niski',
|
||||
frameDetect: 'Wykrywanie klatek',
|
||||
frameDetectTip:
|
||||
'Obliczanie różnicy między klatkami. Zatrzymaj transmisję strumienia wideo, gdy na ekranie zdalnego hosta nie zostaną wykryte żadne zmiany.'
|
||||
|
||||
@@ -37,6 +37,8 @@ const ru = {
|
||||
invalidUser: 'Неверное имя пользователя или пароль',
|
||||
error: 'Непредвиденная ошибка',
|
||||
changePassword: 'Изменить пароль',
|
||||
changePasswordDesc:
|
||||
'Для безопасности вашего устройства измените, пожалуйста, пароль веб-входа.',
|
||||
differentPassword: 'Пароли не совпадают',
|
||||
illegalUsername: 'Имя пользователя содержит недопустимые символы',
|
||||
illegalPassword: 'Пароль содержит недопустимые символы',
|
||||
@@ -50,6 +52,7 @@ const ru = {
|
||||
cancel: 'Отмена'
|
||||
},
|
||||
screen: {
|
||||
video: 'Видеорежим',
|
||||
resolution: 'Разрешение',
|
||||
auto: 'Автоматическое',
|
||||
autoTips:
|
||||
@@ -57,6 +60,10 @@ const ru = {
|
||||
fps: 'Частота кадров',
|
||||
customizeFps: 'Настроить',
|
||||
quality: 'Качество',
|
||||
qualityLossless: 'Без потерь',
|
||||
qualityHigh: 'Высокий',
|
||||
qualityMedium: 'Средний',
|
||||
qualityLow: 'Низкий',
|
||||
frameDetect: 'Экономия трафика',
|
||||
frameDetectTip:
|
||||
'Вычисляет разницу между кадрами и прекращает передачу видеопотока, если на экране удаленного узла не обнаружено никаких изменений.'
|
||||
|
||||
@@ -23,17 +23,20 @@ const uk = {
|
||||
},
|
||||
auth: {
|
||||
login: 'Вхід',
|
||||
placeholderUsername: 'Введіть ім\'я користувача',
|
||||
placeholderUsername: "Введіть ім'я користувача",
|
||||
placeholderPassword: 'введіть пароль',
|
||||
placeholderPassword2: 'введіть пароль ще раз',
|
||||
noEmptyUsername: 'ім\'я користувача не може бути порожнім',
|
||||
noEmptyUsername: "ім'я користувача не може бути порожнім",
|
||||
noEmptyPassword: 'пароль не може бути порожнім',
|
||||
noAccount: 'Не вдалося отримати інформацію про користувача, оновіть веб-сторінку або скиньте пароль',
|
||||
invalidUser: 'недійсне ім\'я користувача або пароль',
|
||||
noAccount:
|
||||
'Не вдалося отримати інформацію про користувача, оновіть веб-сторінку або скиньте пароль',
|
||||
invalidUser: "недійсне ім'я користувача або пароль",
|
||||
error: 'якась халепа! непередбачена помилка :(',
|
||||
changePassword: 'Змінити пароль',
|
||||
changePasswordDesc:
|
||||
'Для безпеки вашого пристрою, будь ласка, змініть пароль для входу в веб-інтерфейс.',
|
||||
differentPassword: 'паролі не збігаються',
|
||||
illegalUsername: 'ім\'я користувача містить недопустимі символи',
|
||||
illegalUsername: "ім'я користувача містить недопустимі символи",
|
||||
illegalPassword: 'пароль містить недопустимі символи',
|
||||
forgetPassword: 'Забули пароль',
|
||||
resetPassword: 'Скинути пароль',
|
||||
@@ -45,15 +48,21 @@ const uk = {
|
||||
cancel: 'Скасувати'
|
||||
},
|
||||
screen: {
|
||||
video: 'Відеорежим',
|
||||
resolution: 'Роздільна здатність',
|
||||
auto: 'Автоматично',
|
||||
autoTips: 'Може виникнути розрив зображення або зміщення миші при певних роздільних здатностях. Розгляньте можливість налаштування роздільної здатності віддаленого хоста або вимкнення автоматичного режиму для передачі відеопотоку.',
|
||||
autoTips:
|
||||
'Може виникнути розрив зображення або зміщення миші при певних роздільних здатностях. Розгляньте можливість налаштування роздільної здатності віддаленого хоста або вимкнення автоматичного режиму для передачі відеопотоку.',
|
||||
fps: 'Кадри в секунду',
|
||||
customizeFps: 'Налаштувати',
|
||||
quality: 'Якість',
|
||||
qualityLossless: 'Без втрат',
|
||||
qualityHigh: 'Високий',
|
||||
qualityMedium: 'Середній',
|
||||
qualityLow: 'Низький',
|
||||
frameDetect: 'Виявлення кадрів',
|
||||
frameDetectTip:
|
||||
"Обчислює різницю між кадрами. Зупиняє передачу відеопотоку, коли на екрані віддаленого хоста не виявлено змін."
|
||||
'Обчислює різницю між кадрами. Зупиняє передачу відеопотоку, коли на екрані віддаленого хоста не виявлено змін.'
|
||||
},
|
||||
keyboard: {
|
||||
paste: 'Вставити',
|
||||
@@ -72,7 +81,8 @@ const uk = {
|
||||
mode: 'Режим миші',
|
||||
absolute: 'Абсолютний режим',
|
||||
relative: 'Відносний режим',
|
||||
requestPointer: 'Використовується відносний режим. Будь ласка, натисніть на робочий стіл, щоб отримати курсор миші.',
|
||||
requestPointer:
|
||||
'Використовується відносний режим. Будь ласка, натисніть на робочий стіл, щоб отримати курсор миші.',
|
||||
resetHid: 'Скинути HID'
|
||||
},
|
||||
image: {
|
||||
@@ -81,19 +91,19 @@ const uk = {
|
||||
empty: 'Нічого не знайдено',
|
||||
mountFailed: 'Не вдалося змонтувати',
|
||||
mountDesc:
|
||||
"У деяких системах необхідно витягнути віртуальний диск на віддаленому хості перед монтуванням файлу образа.",
|
||||
'У деяких системах необхідно витягнути віртуальний диск на віддаленому хості перед монтуванням файлу образа.',
|
||||
tips: {
|
||||
title: 'Як завантажити',
|
||||
usb1: 'Під\'єднайте NanoKVM до вашого комп\'ютера через USB.',
|
||||
usb1: "Під'єднайте NanoKVM до вашого комп'ютера через USB.",
|
||||
usb2: 'Переконайтеся, що віртуальний диск змонтовано (Налаштування - Віртуальний диск).',
|
||||
usb3: 'Відкрийте віртуальний диск на вашому комп\'ютері та скопіюйте файл зображення до кореневого каталогу віртуального диска.',
|
||||
scp1: 'Переконайтеся, що NanoKVM і ваш комп\'ютер знаходяться в одній локальній мережі.',
|
||||
scp2: 'Відкрийте термінал на вашому комп\'ютері та використовуйте команду SCP для завантаження файлу зображення до каталогу /data на NanoKVM.',
|
||||
usb3: "Відкрийте віртуальний диск на вашому комп'ютері та скопіюйте файл зображення до кореневого каталогу віртуального диска.",
|
||||
scp1: "Переконайтеся, що NanoKVM і ваш комп'ютер знаходяться в одній локальній мережі.",
|
||||
scp2: "Відкрийте термінал на вашому комп'ютері та використовуйте команду SCP для завантаження файлу зображення до каталогу /data на NanoKVM.",
|
||||
scp3: 'Приклад: scp ваш-шлях-до-зображення root@ваш-ip-nanokvm:/data',
|
||||
tfCard: 'TF карта',
|
||||
tf1: 'Цей метод підтримується на системах Linux',
|
||||
tf2: 'Отримайте TF карту з NanoKVM (для повної версії, спочатку розберіть корпус).',
|
||||
tf3: 'Вставте TF карту в кардрідер і під\'єднайте її до вашого комп\'ютера.',
|
||||
tf3: "Вставте TF карту в кардрідер і під'єднайте її до вашого комп'ютера.",
|
||||
tf4: 'Скопіюйте файл зображення до каталогу /data на TF карті.',
|
||||
tf5: 'Вставте TF карту в NanoKVM.'
|
||||
}
|
||||
@@ -158,25 +168,25 @@ const uk = {
|
||||
installing: 'Встановлення',
|
||||
failed: 'Не вдалося встановити',
|
||||
retry: 'Будь ласка, оновіть сторінку та спробуйте ще раз. Або спробуйте встановити вручну',
|
||||
download: 'Завантажте',
|
||||
package: 'пакет встановлення',
|
||||
unzip: 'та розпакуйте його',
|
||||
upTailscale: 'Завантажте tailscale до каталогу /usr/bin/ на NanoKVM',
|
||||
upTailscaled: 'Завантажте tailscaled до каталогу /usr/sbin/ на NanoKVM',
|
||||
refresh: 'Оновіть поточну сторінку',
|
||||
notLogin:
|
||||
'Пристрій ще не прив\'язаний. Будь ласка, увійдіть і прив\'яжіть цей пристрій до вашого облікового запису.',
|
||||
urlPeriod: 'Ця URL-адреса дійсна протягом 10 хвилин',
|
||||
login: 'Увійти',
|
||||
loginSuccess: 'Успішний вхід',
|
||||
enable: 'Увімкнути Tailscale',
|
||||
deviceName: 'Назва пристрою',
|
||||
deviceIP: 'IP пристрою',
|
||||
account: 'Обліковий запис',
|
||||
logout: 'Вийти',
|
||||
logout2: 'Ви впевнені, що хочете вийти?'
|
||||
}
|
||||
}
|
||||
download: 'Завантажте',
|
||||
package: 'пакет встановлення',
|
||||
unzip: 'та розпакуйте його',
|
||||
upTailscale: 'Завантажте tailscale до каталогу /usr/bin/ на NanoKVM',
|
||||
upTailscaled: 'Завантажте tailscaled до каталогу /usr/sbin/ на NanoKVM',
|
||||
refresh: 'Оновіть поточну сторінку',
|
||||
notLogin:
|
||||
"Пристрій ще не прив'язаний. Будь ласка, увійдіть і прив'яжіть цей пристрій до вашого облікового запису.",
|
||||
urlPeriod: 'Ця URL-адреса дійсна протягом 10 хвилин',
|
||||
login: 'Увійти',
|
||||
loginSuccess: 'Успішний вхід',
|
||||
enable: 'Увімкнути Tailscale',
|
||||
deviceName: 'Назва пристрою',
|
||||
deviceIP: 'IP пристрою',
|
||||
account: 'Обліковий запис',
|
||||
logout: 'Вийти',
|
||||
logout2: 'Ви впевнені, що хочете вийти?'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default uk;
|
||||
|
||||
@@ -19,7 +19,7 @@ const vi = {
|
||||
desktop: 'Remote Desktop',
|
||||
login: 'Đăng nhập',
|
||||
changePassword: 'Đổi mật khẩu',
|
||||
terminal: 'Terminal',
|
||||
terminal: 'Terminal'
|
||||
},
|
||||
auth: {
|
||||
login: 'Đăng nhập',
|
||||
@@ -33,6 +33,7 @@ const vi = {
|
||||
invalidUser: 'tên người dùng hoặc mật khẩu không hợp lệ',
|
||||
error: 'lỗi không mong đợi',
|
||||
changePassword: 'Đổi mật khẩu',
|
||||
changePasswordDesc: 'Để bảo mật thiết bị của bạn, vui lòng thay đổi mật khẩu đăng nhập web.',
|
||||
differentPassword: 'mật khẩu không khớp',
|
||||
illegalUsername: 'tên người dùng chứa ký tự không hợp lệ',
|
||||
illegalPassword: 'mật khẩu chứa ký tự không hợp lệ',
|
||||
@@ -43,9 +44,10 @@ const vi = {
|
||||
reset3: '2. Xóa tệp trong thiết bị: ',
|
||||
reset4: '3. Sử dụng tài khoản mặc định để đăng nhập: ',
|
||||
ok: 'OK',
|
||||
cancel: 'Hủy',
|
||||
cancel: 'Hủy'
|
||||
},
|
||||
screen: {
|
||||
video: 'Chế độ video',
|
||||
resolution: 'Độ phân giải',
|
||||
auto: 'Tự động',
|
||||
autoTips:
|
||||
@@ -53,16 +55,20 @@ const vi = {
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Tùy chỉnh',
|
||||
quality: 'Chất lượng',
|
||||
qualityLossless: 'Không mất dữ liệu',
|
||||
qualityHigh: 'Cao',
|
||||
qualityMedium: 'Trung bình',
|
||||
qualityLow: 'Thấp',
|
||||
frameDetect: 'Phát hiện khung hình',
|
||||
frameDetectTip:
|
||||
'Tính toán sự khác biệt giữa các khung hình. Dừng truyền video khi không có thay đổi trên màn hình máy chủ từ xa.',
|
||||
'Tính toán sự khác biệt giữa các khung hình. Dừng truyền video khi không có thay đổi trên màn hình máy chủ từ xa.'
|
||||
},
|
||||
keyboard: {
|
||||
paste: 'Dán',
|
||||
tips: 'Chỉ hỗ trợ các chữ cái và ký hiệu bàn phím tiêu chuẩn',
|
||||
placeholder: 'Vui lòng nhập',
|
||||
submit: 'Gửi',
|
||||
virtual: 'Bàn phím',
|
||||
virtual: 'Bàn phím'
|
||||
},
|
||||
mouse: {
|
||||
default: 'Con trỏ mặc định',
|
||||
@@ -76,15 +82,14 @@ const vi = {
|
||||
relative: 'Chế độ tương đối',
|
||||
requestPointer:
|
||||
'Đang sử dụng chế độ tương đối. Vui lòng nhấp vào màn hình để lấy con trỏ chuột.',
|
||||
resetHid: 'Đặt lại HID',
|
||||
resetHid: 'Đặt lại HID'
|
||||
},
|
||||
image: {
|
||||
title: 'Hình ảnh',
|
||||
loading: 'Đang tải...',
|
||||
empty: 'Không tìm thấy',
|
||||
mountFailed: 'Mount thất bại',
|
||||
mountDesc:
|
||||
'Trong một số hệ thống, cần phải eject đĩa ảo trên máy remote trước khi mount.',
|
||||
mountDesc: 'Trong một số hệ thống, cần phải eject đĩa ảo trên máy remote trước khi mount.',
|
||||
tips: {
|
||||
title: 'Cách tải lên',
|
||||
usb1: 'Kết nối NanoKVM với máy tính của bạn qua USB.',
|
||||
@@ -98,8 +103,8 @@ const vi = {
|
||||
tf2: 'Lấy thẻ TF từ NanoKVM (với phiên bản FULL, hãy tháo vỏ trước).',
|
||||
tf3: 'Chèn thẻ TF vào đầu đọc thẻ và kết nối với máy tính của bạn.',
|
||||
tf4: 'Sao chép tệp hình ảnh vào thư mục /data trên thẻ TF.',
|
||||
tf5: 'Chèn thẻ TF vào NanoKVM.',
|
||||
},
|
||||
tf5: 'Chèn thẻ TF vào NanoKVM.'
|
||||
}
|
||||
},
|
||||
script: {
|
||||
title: 'Script',
|
||||
@@ -112,7 +117,7 @@ const vi = {
|
||||
confirm: 'Có',
|
||||
cancel: 'Không',
|
||||
delete: 'Xóa',
|
||||
close: 'Đóng',
|
||||
close: 'Đóng'
|
||||
},
|
||||
terminal: {
|
||||
title: 'Terminal',
|
||||
@@ -121,13 +126,13 @@ const vi = {
|
||||
serialPort: 'Cổng Nối Tiếp',
|
||||
serialPortPlaceholder: 'Vui lòng nhập cổng nối tiếp',
|
||||
baudrate: 'Tốc độ Baud',
|
||||
confirm: 'OK',
|
||||
confirm: 'OK'
|
||||
},
|
||||
wol: {
|
||||
sending: 'Đang gửi lệnh...',
|
||||
sent: 'Đã gửi lệnh',
|
||||
input: 'Vui lòng nhập địa chỉ MAC',
|
||||
ok: 'OK',
|
||||
ok: 'OK'
|
||||
},
|
||||
about: {
|
||||
title: 'Giới thiệu về NanoKVM',
|
||||
@@ -138,7 +143,7 @@ const vi = {
|
||||
image: 'Phiên bản Hình ảnh',
|
||||
deviceKey: 'Khóa Thiết bị',
|
||||
queryFailed: 'Truy vấn thất bại',
|
||||
community: 'Cộng đồng',
|
||||
community: 'Cộng đồng'
|
||||
},
|
||||
update: {
|
||||
title: 'Kiểm tra cập nhật',
|
||||
@@ -148,11 +153,11 @@ const vi = {
|
||||
available: 'Có bản cập nhật mới. Bạn có chắc chắn muốn cập nhật không?',
|
||||
updating: 'Bắt đầu cập nhật. Vui lòng chờ...',
|
||||
confirm: 'Xác nhận',
|
||||
cancel: 'Hủy',
|
||||
cancel: 'Hủy'
|
||||
},
|
||||
virtualDevice: {
|
||||
network: 'Mạng Ảo',
|
||||
disk: 'Đĩa Ảo',
|
||||
disk: 'Đĩa Ảo'
|
||||
},
|
||||
tailscale: {
|
||||
loading: 'Đang tải...',
|
||||
@@ -177,9 +182,9 @@ const vi = {
|
||||
deviceIP: 'IP Thiết bị',
|
||||
account: 'Tài khoản',
|
||||
logout: 'Đăng xuất',
|
||||
logout2: 'Bạn có chắc chắn muốn đăng xuất không?',
|
||||
},
|
||||
},
|
||||
logout2: 'Bạn có chắc chắn muốn đăng xuất không?'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default vi;
|
||||
|
||||
@@ -32,6 +32,7 @@ const zh = {
|
||||
invalidUser: '用户名或密码错误',
|
||||
error: '未知错误',
|
||||
changePassword: '修改密码',
|
||||
changePasswordDesc: '为了您的设备安全,请修改网页登录密码。',
|
||||
differentPassword: '两次密码不一致',
|
||||
illegalUsername: '用户名中包含非法字符',
|
||||
illegalPassword: '密码中包含非法字符',
|
||||
@@ -45,6 +46,7 @@ const zh = {
|
||||
cancel: '取消'
|
||||
},
|
||||
screen: {
|
||||
video: '视频模式',
|
||||
resolution: '分辨率',
|
||||
auto: '自动',
|
||||
autoTips:
|
||||
@@ -52,6 +54,10 @@ const zh = {
|
||||
fps: '帧率',
|
||||
customizeFps: '自定义',
|
||||
quality: '图像质量',
|
||||
qualityLossless: '无损',
|
||||
qualityHigh: '高',
|
||||
qualityMedium: '中',
|
||||
qualityLow: '低',
|
||||
frameDetect: '帧差检测',
|
||||
frameDetectTip: '计算帧之间的差异,当检测到远程主机画面不变时,停止传输视频流'
|
||||
},
|
||||
|
||||
@@ -2,8 +2,8 @@ import { atom } from 'jotai';
|
||||
|
||||
import { Resolution } from '@/types';
|
||||
|
||||
// mjpeg stream url
|
||||
export const streamUrlAtom = atom('');
|
||||
// video mode: h264 or mjpeg
|
||||
export const videoModeAtom = atom('');
|
||||
|
||||
// browser screen resolution
|
||||
export const resolutionAtom = atom<Resolution | null>(null);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Resolution } from '@/types';
|
||||
|
||||
const LANGUAGE_KEY = 'nano-kvm-language';
|
||||
const VIDEO_MODE_KEY = 'nano-kvm-vide-mode';
|
||||
const WEB_RESOLUTION_KEY = 'nano-kvm-web-resolution';
|
||||
const FPS_KEY = 'nano-kvm-fps';
|
||||
const QUALITY_KEY = 'nano-kvm-quality';
|
||||
@@ -8,6 +9,7 @@ const MOUSE_STYLE_KEY = 'nano-kvm-mouse-style';
|
||||
const MOUSE_MODE_KEY = 'nano-kvm-mouse-mode';
|
||||
const SKIP_UPDATE_KEY = 'nano-kvm-check-update';
|
||||
const KEYBOARD_LAYOUT_KEY = 'nano-kvm-keyboard-layout';
|
||||
const SKIP_MODIFY_PASSWORD = 'nano-kvm-skip-modify-password';
|
||||
|
||||
type ItemWithExpiry = {
|
||||
value: string;
|
||||
@@ -49,6 +51,14 @@ export function setLanguage(language: string) {
|
||||
localStorage.setItem(LANGUAGE_KEY, language);
|
||||
}
|
||||
|
||||
export function getVideoMode() {
|
||||
return localStorage.getItem(VIDEO_MODE_KEY);
|
||||
}
|
||||
|
||||
export function setVideoMode(mode: string) {
|
||||
localStorage.setItem(VIDEO_MODE_KEY, mode);
|
||||
}
|
||||
|
||||
export function getResolution(): Resolution | null {
|
||||
const resolution = localStorage.getItem(WEB_RESOLUTION_KEY);
|
||||
if (resolution) {
|
||||
@@ -99,14 +109,11 @@ export function setMouseMode(mouse: string) {
|
||||
|
||||
export function getSkipUpdate() {
|
||||
const skip = getWithExpiry(SKIP_UPDATE_KEY);
|
||||
if (skip) {
|
||||
return Boolean(skip);
|
||||
}
|
||||
return false;
|
||||
return skip ? Boolean(skip) : false;
|
||||
}
|
||||
|
||||
export function setSkipUpdate(skip: boolean) {
|
||||
const expiry = 3 * 24 * 60 * 60 * 1000; // 3天
|
||||
const expiry = 3 * 24 * 60 * 60 * 1000; // 3 days
|
||||
setWithExpiry(SKIP_UPDATE_KEY, String(skip), expiry);
|
||||
}
|
||||
|
||||
@@ -117,3 +124,13 @@ export function setKeyboardLayout(layout: string) {
|
||||
export function getKeyboardLayout() {
|
||||
return localStorage.getItem(KEYBOARD_LAYOUT_KEY);
|
||||
}
|
||||
|
||||
export function setSkipModifyPassword(skip: boolean) {
|
||||
const expiry = 3 * 24 * 60 * 60 * 1000; // 3 days
|
||||
setWithExpiry(SKIP_MODIFY_PASSWORD, String(skip), expiry);
|
||||
}
|
||||
|
||||
export function getSkipModifyPassword() {
|
||||
const skip = getWithExpiry(SKIP_MODIFY_PASSWORD);
|
||||
return skip ? Boolean(skip) : false;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ export const Password = () => {
|
||||
return !regex.test(str);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
window.location.replace('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={t('head.changePassword')} />
|
||||
@@ -105,7 +109,7 @@ export const Password = () => {
|
||||
<Button type="primary" htmlType="submit" className="w-1/2">
|
||||
{t('auth.ok')}
|
||||
</Button>
|
||||
<Button className="w-1/2" onClick={() => navigate('/')}>
|
||||
<Button className="w-1/2" onClick={cancel}>
|
||||
{t('auth.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,57 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import { getResolution } from '@/lib/localstorage.ts';
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { getResolution, getVideoMode } from '@/lib/localstorage.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
import { resolutionAtom, streamUrlAtom } from '@/jotai/screen.ts';
|
||||
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
|
||||
import { Head } from '@/components/head.tsx';
|
||||
|
||||
import { Keyboard } from './keyboard';
|
||||
import { VirtualKeyboard } from './keyboard/virtual-keyboard';
|
||||
import { Lib } from './lib.tsx';
|
||||
import { Menu } from './menu';
|
||||
import { MenuPhone } from './menu-phone';
|
||||
import { Mouse } from './mouse';
|
||||
import { Notification } from './notification.tsx';
|
||||
import { Screen } from './screen';
|
||||
|
||||
export const Desktop = () => {
|
||||
const { t } = useTranslation();
|
||||
const isBigScreen = useMediaQuery({ minWidth: 850 });
|
||||
|
||||
const [videoMode, setVideoMode] = useAtom(videoModeAtom);
|
||||
const [resolution, setResolution] = useAtom(resolutionAtom);
|
||||
const setStreamUrl = useSetAtom(streamUrlAtom);
|
||||
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [tips, setTips] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const cookieVideoMode = getVideoMode();
|
||||
setVideoMode(cookieVideoMode ? cookieVideoMode : window.RTCPeerConnection ? 'h264' : 'mjpeg');
|
||||
|
||||
const cookieResolution = getResolution();
|
||||
setResolution(cookieResolution ? cookieResolution : { width: 0, height: 0 });
|
||||
|
||||
setStreamUrl(`${getBaseUrl('http')}/api/stream/mjpeg`);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
client.send([0]);
|
||||
}, 1000 * 60);
|
||||
|
||||
client.register('stream', (message) => {
|
||||
const data = JSON.parse(message.data as string);
|
||||
|
||||
if (data.state === 0) {
|
||||
setIsLoading(true);
|
||||
setTimeout(() => setIsLoading(false), 5000);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
const now = Date.now();
|
||||
setStreamUrl(`${getBaseUrl('http')}/api/stream/mjpeg?n=${now}`);
|
||||
}
|
||||
});
|
||||
}, 60 * 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
@@ -64,11 +47,9 @@ export const Desktop = () => {
|
||||
<>
|
||||
<Head title={t('head.desktop')} />
|
||||
|
||||
<Spin spinning={isLoading} tip={tips} size="large" fullscreen />
|
||||
{isBigScreen && <Notification />}
|
||||
|
||||
<Lib setIsLoading={setIsLoading} setTips={setTips} />
|
||||
|
||||
{resolution && (
|
||||
{videoMode && resolution && (
|
||||
<>
|
||||
{isBigScreen ? <Menu /> : <MenuPhone />}
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/application.ts';
|
||||
|
||||
type LibProps = {
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
setTips: (tips: string) => void;
|
||||
};
|
||||
|
||||
export const Lib = ({ setIsLoading, setTips }: LibProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
useEffect(() => {
|
||||
getLib();
|
||||
}, []);
|
||||
|
||||
function getLib() {
|
||||
api.getLib().then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
showMessage(t('checkLibFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rsp.data.exist) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadLib();
|
||||
});
|
||||
}
|
||||
|
||||
function downloadLib() {
|
||||
setIsLoading(true);
|
||||
setTips(t('updatingLib'));
|
||||
|
||||
api
|
||||
.updateLib()
|
||||
.then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
showMessage(t('updateLibFailed'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
setTips('');
|
||||
|
||||
window.location.reload();
|
||||
}, 6000);
|
||||
});
|
||||
}
|
||||
|
||||
function showMessage(content: string) {
|
||||
messageApi.open({
|
||||
type: 'warning',
|
||||
content,
|
||||
duration: 10,
|
||||
style: {
|
||||
marginTop: '8vh'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return <>{contextHolder}</>;
|
||||
};
|
||||
13
web/src/pages/desktop/menu-phone/screen/constants.ts
Normal file
13
web/src/pages/desktop/menu-phone/screen/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const QualityMap = new Map([
|
||||
[1, 100],
|
||||
[2, 80],
|
||||
[3, 60],
|
||||
[4, 50]
|
||||
]);
|
||||
|
||||
export const BitRateMap = new Map([
|
||||
[1, 5000],
|
||||
[2, 3000],
|
||||
[3, 2000],
|
||||
[4, 1000]
|
||||
]);
|
||||
@@ -5,48 +5,63 @@ import { MonitorIcon } from 'lucide-react';
|
||||
|
||||
import { updateScreen } from '@/api/vm';
|
||||
import * as ls from '@/lib/localstorage';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { BitRateMap, QualityMap } from './constants.ts';
|
||||
import { Fps } from './fps';
|
||||
import { FrameDetect } from './frame-detect';
|
||||
import { Quality } from './quality';
|
||||
import { Resolution } from './resolution';
|
||||
import { VideoMode } from './video-mode.tsx';
|
||||
|
||||
export const Screen = () => {
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const [fps, setFps] = useState(30);
|
||||
const [quality, setQuality] = useState(80);
|
||||
|
||||
useEffect(() => {
|
||||
updateScreen('type', videoMode === 'mjpeg' ? 0 : 1);
|
||||
updateScreen('resolution', resolution!.height);
|
||||
|
||||
const cookieFps = ls.getFps();
|
||||
if (cookieFps) {
|
||||
updateScreen('fps', cookieFps).then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setFps(cookieFps);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const cookieQuality = ls.getQuality();
|
||||
if (cookieQuality) {
|
||||
updateScreen('quality', cookieQuality).then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setQuality(cookieQuality);
|
||||
}
|
||||
});
|
||||
}
|
||||
updateQuality();
|
||||
updateFps();
|
||||
}, []);
|
||||
|
||||
function updateQuality() {
|
||||
const cookieQuality = ls.getQuality();
|
||||
if (!cookieQuality) return;
|
||||
|
||||
const key = cookieQuality >= 1 && cookieQuality <= 4 ? cookieQuality : 2;
|
||||
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!;
|
||||
|
||||
updateScreen('quality', value).then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setQuality(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateFps() {
|
||||
const cookieFps = ls.getFps();
|
||||
if (!cookieFps) return;
|
||||
|
||||
updateScreen('fps', cookieFps).then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setFps(cookieFps);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
<div className="flex flex-col space-y-1">
|
||||
<VideoMode />
|
||||
<Resolution />
|
||||
<Fps fps={fps} setFps={setFps} />
|
||||
<Quality quality={quality} setQuality={setQuality} />
|
||||
<FrameDetect />
|
||||
<Fps fps={fps} setFps={setFps} />
|
||||
{videoMode === 'mjpeg' && <FrameDetect />}
|
||||
</div>
|
||||
}
|
||||
placement="rightBottom"
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { Popover } from 'antd';
|
||||
import { useAtomValue } from 'jotai/index';
|
||||
import { CheckIcon, SquareActivityIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { updateScreen } from '@/api/vm';
|
||||
import { setQuality as setCookie } from '@/lib/localstorage.ts';
|
||||
import { videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
const qualityList = [
|
||||
{ key: 99, label: '100%' },
|
||||
{ key: 90, label: '90%' },
|
||||
{ key: 80, label: '80%' },
|
||||
{ key: 70, label: '70%' },
|
||||
{ key: 60, label: '60%' },
|
||||
{ key: 51, label: '50%' }
|
||||
];
|
||||
import { BitRateMap, QualityMap } from './constants.ts';
|
||||
|
||||
type QualityProps = {
|
||||
quality: number;
|
||||
@@ -21,16 +16,25 @@ type QualityProps = {
|
||||
|
||||
export const Quality = ({ quality, setQuality }: QualityProps) => {
|
||||
const { t } = useTranslation();
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
|
||||
const qualityList = [
|
||||
{ key: 1, label: t('screen.qualityLossless') },
|
||||
{ key: 2, label: t('screen.qualityHigh') },
|
||||
{ key: 3, label: t('screen.qualityMedium') },
|
||||
{ key: 4, label: t('screen.qualityLow') }
|
||||
];
|
||||
|
||||
async function update(key: number) {
|
||||
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!;
|
||||
|
||||
async function update(value: number) {
|
||||
const rsp = await updateScreen('quality', value);
|
||||
|
||||
if (rsp.code !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setQuality(value);
|
||||
setCookie(value);
|
||||
setQuality(key);
|
||||
setCookie(key);
|
||||
}
|
||||
|
||||
const content = (
|
||||
@@ -44,7 +48,7 @@ export const Quality = ({ quality, setQuality }: QualityProps) => {
|
||||
<div className="flex h-[14px] w-[20px] items-end">
|
||||
{item.key === quality && <CheckIcon size={14} />}
|
||||
</div>
|
||||
<span className="flex w-[32px]">{item.label}</span>
|
||||
<span className="flex w-[40px]">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
|
||||
55
web/src/pages/desktop/menu-phone/screen/video-mode.tsx
Normal file
55
web/src/pages/desktop/menu-phone/screen/video-mode.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Popover } from 'antd';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { CheckIcon, TvMinimalPlayIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { setVideoMode as setCookie } from '@/lib/localstorage.ts';
|
||||
import { videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
const videoModes = [
|
||||
{ key: 'h264', name: 'H.264' },
|
||||
{ key: 'mjpeg', name: 'MJPEG' }
|
||||
];
|
||||
|
||||
export const VideoMode = () => {
|
||||
const { t } = useTranslation();
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
|
||||
function update(mode: string) {
|
||||
if (mode === videoMode) return;
|
||||
|
||||
setCookie(mode);
|
||||
|
||||
// reload after changing video mode
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{videoModes.map((mode) => (
|
||||
<div
|
||||
key={mode.key}
|
||||
className="flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 pr-5 hover:bg-neutral-600"
|
||||
onClick={() => update(mode.key)}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end">
|
||||
{mode.key === videoMode && <CheckIcon size={15} />}
|
||||
</div>
|
||||
|
||||
<span className="flex w-[50px]">{mode.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop">
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||
<TvMinimalPlayIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.video')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
13
web/src/pages/desktop/menu/screen/constants.ts
Normal file
13
web/src/pages/desktop/menu/screen/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const QualityMap = new Map([
|
||||
[1, 100],
|
||||
[2, 80],
|
||||
[3, 60],
|
||||
[4, 50]
|
||||
]);
|
||||
|
||||
export const BitRateMap = new Map([
|
||||
[1, 5000],
|
||||
[2, 3000],
|
||||
[3, 2000],
|
||||
[4, 1000]
|
||||
]);
|
||||
@@ -5,48 +5,63 @@ import { MonitorIcon } from 'lucide-react';
|
||||
|
||||
import { updateScreen } from '@/api/vm';
|
||||
import * as ls from '@/lib/localstorage';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { BitRateMap, QualityMap } from './constants.ts';
|
||||
import { Fps } from './fps';
|
||||
import { FrameDetect } from './frame-detect';
|
||||
import { Quality } from './quality';
|
||||
import { Resolution } from './resolution';
|
||||
import { VideoMode } from './video-mode.tsx';
|
||||
|
||||
export const Screen = () => {
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const [fps, setFps] = useState(30);
|
||||
const [quality, setQuality] = useState(80);
|
||||
|
||||
useEffect(() => {
|
||||
updateScreen('type', videoMode === 'mjpeg' ? 0 : 1);
|
||||
updateScreen('resolution', resolution!.height);
|
||||
|
||||
const cookieFps = ls.getFps();
|
||||
if (cookieFps) {
|
||||
updateScreen('fps', cookieFps).then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setFps(cookieFps);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const cookieQuality = ls.getQuality();
|
||||
if (cookieQuality) {
|
||||
updateScreen('quality', cookieQuality).then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setQuality(cookieQuality);
|
||||
}
|
||||
});
|
||||
}
|
||||
updateQuality();
|
||||
updateFps();
|
||||
}, []);
|
||||
|
||||
function updateQuality() {
|
||||
const cookieQuality = ls.getQuality();
|
||||
if (!cookieQuality) return;
|
||||
|
||||
const key = cookieQuality >= 1 && cookieQuality <= 4 ? cookieQuality : 2;
|
||||
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!;
|
||||
|
||||
updateScreen('quality', value).then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setQuality(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateFps() {
|
||||
const cookieFps = ls.getFps();
|
||||
if (!cookieFps) return;
|
||||
|
||||
updateScreen('fps', cookieFps).then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setFps(cookieFps);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
<div className="flex flex-col space-y-1">
|
||||
<VideoMode />
|
||||
<Resolution />
|
||||
<Fps fps={fps} setFps={setFps} />
|
||||
<Quality quality={quality} setQuality={setQuality} />
|
||||
<FrameDetect />
|
||||
<Fps fps={fps} setFps={setFps} />
|
||||
{videoMode === 'mjpeg' && <FrameDetect />}
|
||||
</div>
|
||||
}
|
||||
placement="bottomLeft"
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { Popover } from 'antd';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { CheckIcon, SquareActivityIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { updateScreen } from '@/api/vm';
|
||||
import { setQuality as setCookie } from '@/lib/localstorage.ts';
|
||||
import { videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
const qualityList = [
|
||||
{ key: 99, label: '100%' },
|
||||
{ key: 90, label: '90%' },
|
||||
{ key: 80, label: '80%' },
|
||||
{ key: 70, label: '70%' },
|
||||
{ key: 60, label: '60%' },
|
||||
{ key: 51, label: '50%' }
|
||||
];
|
||||
import { BitRateMap, QualityMap } from './constants.ts';
|
||||
|
||||
type QualityProps = {
|
||||
quality: number;
|
||||
@@ -21,16 +16,25 @@ type QualityProps = {
|
||||
|
||||
export const Quality = ({ quality, setQuality }: QualityProps) => {
|
||||
const { t } = useTranslation();
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
|
||||
const qualityList = [
|
||||
{ key: 1, label: t('screen.qualityLossless') },
|
||||
{ key: 2, label: t('screen.qualityHigh') },
|
||||
{ key: 3, label: t('screen.qualityMedium') },
|
||||
{ key: 4, label: t('screen.qualityLow') }
|
||||
];
|
||||
|
||||
async function update(key: number) {
|
||||
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!;
|
||||
|
||||
async function update(value: number) {
|
||||
const rsp = await updateScreen('quality', value);
|
||||
|
||||
if (rsp.code !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setQuality(value);
|
||||
setCookie(value);
|
||||
setQuality(key);
|
||||
setCookie(key);
|
||||
}
|
||||
|
||||
const content = (
|
||||
@@ -44,7 +48,7 @@ export const Quality = ({ quality, setQuality }: QualityProps) => {
|
||||
<div className="flex h-[14px] w-[20px] items-end">
|
||||
{item.key === quality && <CheckIcon size={14} />}
|
||||
</div>
|
||||
<span className="flex w-[32px]">{item.label}</span>
|
||||
<span className="flex w-[50px]">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
|
||||
55
web/src/pages/desktop/menu/screen/video-mode.tsx
Normal file
55
web/src/pages/desktop/menu/screen/video-mode.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Popover } from 'antd';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { CheckIcon, TvMinimalPlayIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { setVideoMode as setCookie } from '@/lib/localstorage.ts';
|
||||
import { videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
const videoModes = [
|
||||
{ key: 'h264', name: 'H.264' },
|
||||
{ key: 'mjpeg', name: 'MJPEG' }
|
||||
];
|
||||
|
||||
export const VideoMode = () => {
|
||||
const { t } = useTranslation();
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
|
||||
function update(mode: string) {
|
||||
if (mode === videoMode) return;
|
||||
|
||||
setCookie(mode);
|
||||
|
||||
// reload after changing video mode
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{videoModes.map((mode) => (
|
||||
<div
|
||||
key={mode.key}
|
||||
className="flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 pr-5 hover:bg-neutral-600"
|
||||
onClick={() => update(mode.key)}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end">
|
||||
{mode.key === videoMode && <CheckIcon size={15} />}
|
||||
</div>
|
||||
|
||||
<span className="flex w-[50px]">{mode.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop">
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
|
||||
<TvMinimalPlayIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.video')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -2,12 +2,11 @@ import { useEffect } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { resolutionAtom, streamUrlAtom } from '@/jotai/screen.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { MouseButton, MouseEvent } from './constants';
|
||||
|
||||
export const Absolute = () => {
|
||||
const streamUrl = useAtomValue(streamUrlAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
|
||||
// listen mouse events
|
||||
@@ -86,7 +85,7 @@ export const Absolute = () => {
|
||||
canvas.removeEventListener('click', disableEvent);
|
||||
canvas.removeEventListener('contextmenu', disableEvent);
|
||||
};
|
||||
}, [resolution, streamUrl]);
|
||||
}, [resolution]);
|
||||
|
||||
// disable default events
|
||||
function disableEvent(event: any) {
|
||||
|
||||
@@ -4,14 +4,13 @@ import { useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { resolutionAtom, streamUrlAtom } from '@/jotai/screen.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { MouseButton, MouseEvent } from './constants';
|
||||
|
||||
export const Relative = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const streamUrl = useAtomValue(streamUrlAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
|
||||
const isLockedRef = useRef(false);
|
||||
@@ -126,7 +125,7 @@ export const Relative = () => {
|
||||
canvas.removeEventListener('mouseup', handleMouseUp);
|
||||
canvas.removeEventListener('contextmenu', disableEvent);
|
||||
};
|
||||
}, [resolution, streamUrl]);
|
||||
}, [resolution]);
|
||||
|
||||
// disable default events
|
||||
function disableEvent(event: any) {
|
||||
|
||||
42
web/src/pages/desktop/notification.tsx
Normal file
42
web/src/pages/desktop/notification.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button, notification } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { isPasswordUpdated } from '@/api/auth.ts';
|
||||
import { getSkipModifyPassword, setSkipModifyPassword } from '@/lib/localstorage.ts';
|
||||
|
||||
export const Notification = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [api, contextHolder] = notification.useNotification();
|
||||
|
||||
useEffect(() => {
|
||||
const skip = getSkipModifyPassword();
|
||||
if (skip) return;
|
||||
|
||||
isPasswordUpdated().then((rsp) => {
|
||||
if (rsp.code === 0 && !rsp.data.isUpdated) {
|
||||
openNotification();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
function openNotification() {
|
||||
api.info({
|
||||
message: t('auth.changePassword'),
|
||||
description: t('auth.changePasswordDesc'),
|
||||
placement: 'topRight',
|
||||
btn: <Button onClick={changePassword}>{t('auth.ok')}</Button>,
|
||||
duration: null,
|
||||
onClose: () => setSkipModifyPassword(true)
|
||||
});
|
||||
}
|
||||
|
||||
function changePassword() {
|
||||
api.destroy();
|
||||
navigate('/auth/password');
|
||||
}
|
||||
|
||||
return <>{contextHolder}</>;
|
||||
};
|
||||
121
web/src/pages/desktop/screen/h264.tsx
Normal file
121
web/src/pages/desktop/screen/h264.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { w3cwebsocket as W3cWebSocket } from 'websocket';
|
||||
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
export const H264 = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let heartbeatTimer: any;
|
||||
const videoElement = document.getElementById('screen') as HTMLVideoElement;
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
});
|
||||
|
||||
pc.ontrack = function (event) {
|
||||
if (event.track.kind !== 'video') {
|
||||
console.log('unhandled track kind: ', event.track.kind);
|
||||
return;
|
||||
}
|
||||
videoElement.srcObject = event.streams[0];
|
||||
};
|
||||
|
||||
const url = `${getBaseUrl('ws')}/api/stream/h264`;
|
||||
const ws = new W3cWebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
ws.send(JSON.stringify({ event: 'candidate', data: JSON.stringify(event.candidate) }));
|
||||
}
|
||||
};
|
||||
|
||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||
|
||||
pc.createOffer({ offerToReceiveVideo: true })
|
||||
.then((offer) => {
|
||||
pc.setLocalDescription(offer).catch(console.log);
|
||||
ws.send(JSON.stringify({ event: 'offer', data: JSON.stringify(offer) }));
|
||||
})
|
||||
.catch(console.log);
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
ws.send(JSON.stringify({ event: 'heartbeat', data: '' }));
|
||||
}, 60 * 1000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
if (!msg) return;
|
||||
|
||||
const data = JSON.parse(msg.data);
|
||||
if (!data) return;
|
||||
|
||||
switch (msg.event) {
|
||||
case 'answer':
|
||||
pc.setRemoteDescription(data).catch(console.log);
|
||||
break;
|
||||
|
||||
case 'candidate':
|
||||
pc.addIceCandidate(data).catch(console.log);
|
||||
break;
|
||||
|
||||
case 'heartbeat':
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('unhandled event: ', msg.event);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 10 * 1000);
|
||||
|
||||
return () => {
|
||||
heartbeatTimer && clearInterval(heartbeatTimer);
|
||||
ws.close();
|
||||
pc.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <Spin indicator={<LoadingOutlined spin />} size="large" fullscreen />}
|
||||
|
||||
<div className={clsx('flex h-screen w-screen items-start justify-center xl:items-center')}>
|
||||
<video
|
||||
id="screen"
|
||||
className={clsx('block select-none bg-neutral-950', mouseStyle)}
|
||||
style={
|
||||
resolution?.width
|
||||
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }
|
||||
: { maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }
|
||||
}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
controls={false}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onPlaying={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,37 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Image } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import MonitorXIcon from '@/assets/images/monitor-x.svg';
|
||||
import { stopFrameDetect } from '@/api/stream.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom, streamUrlAtom } from '@/jotai/screen.ts';
|
||||
import { videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { H264 } from './h264.tsx';
|
||||
import { Mjpeg } from './mjpeg.tsx';
|
||||
|
||||
export const Screen = () => {
|
||||
const streamUrl = useAtomValue(streamUrlAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
|
||||
useEffect(() => {
|
||||
// stop frame detect for a while
|
||||
stopFrameDetect();
|
||||
}, [resolution]);
|
||||
|
||||
return (
|
||||
<div className={clsx('flex h-screen w-screen items-start justify-center xl:items-center')}>
|
||||
<Image
|
||||
id="screen"
|
||||
className={clsx('block select-none bg-neutral-950', mouseStyle)}
|
||||
style={
|
||||
resolution?.width
|
||||
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }
|
||||
: { maxHeight: '100vh', objectFit: 'scale-down' }
|
||||
}
|
||||
src={streamUrl}
|
||||
fallback={MonitorXIcon}
|
||||
preview={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <>{videoMode === 'mjpeg' ? <Mjpeg /> : <H264 />}</>;
|
||||
};
|
||||
|
||||
37
web/src/pages/desktop/screen/mjpeg.tsx
Normal file
37
web/src/pages/desktop/screen/mjpeg.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Image } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import MonitorXIcon from '@/assets/images/monitor-x.svg';
|
||||
import { stopFrameDetect } from '@/api/stream.ts';
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
export const Mjpeg = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
|
||||
useEffect(() => {
|
||||
// stop frame detect for a while
|
||||
stopFrameDetect();
|
||||
}, [resolution]);
|
||||
|
||||
return (
|
||||
<div className={clsx('flex h-screen w-screen items-start justify-center xl:items-center')}>
|
||||
<Image
|
||||
id="screen"
|
||||
className={clsx('block select-none bg-neutral-950', mouseStyle)}
|
||||
style={
|
||||
resolution?.width
|
||||
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }
|
||||
: { maxHeight: '100vh', objectFit: 'scale-down' }
|
||||
}
|
||||
src={`${getBaseUrl('http')}/api/stream/mjpeg`}
|
||||
fallback={MonitorXIcon}
|
||||
preview={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user