diff --git a/server/README.md b/server/README.md index eebecfb..6431732 100644 --- a/server/README.md +++ b/server/README.md @@ -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. \ No newline at end of file diff --git a/server/README_ZH.md b/server/README_ZH.md index 2edb362..3f78ede 100644 --- a/server/README_ZH.md +++ b/server/README_ZH.md @@ -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 并安装工具链后才能编译。这部分内容会晚一点更新。 diff --git a/server/common/cgo.go b/server/common/cgo.go new file mode 100644 index 0000000..4e0a6a1 --- /dev/null +++ b/server/common/cgo.go @@ -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...") +} diff --git a/server/common/screen.go b/server/common/screen.go new file mode 100644 index 0000000..8059bd5 --- /dev/null +++ b/server/common/screen.go @@ -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 +} diff --git a/server/config/config.go b/server/config/config.go index a16f67e..f1367a8 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -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 { diff --git a/server/config/default.go b/server/config/default.go new file mode 100644 index 0000000..6ae2c87 --- /dev/null +++ b/server/config/default.go @@ -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", +} diff --git a/server/dl_lib/libkvm.so b/server/dl_lib/libkvm.so new file mode 100644 index 0000000..e6dc981 Binary files /dev/null and b/server/dl_lib/libkvm.so differ diff --git a/server/include/kvm_vision.h b/server/include/kvm_vision.h new file mode 100644 index 0000000..b8b97e0 --- /dev/null +++ b/server/include/kvm_vision.h @@ -0,0 +1,59 @@ +#ifndef KVM_VISION_H_ +#define KVM_VISION_H_ + +#ifdef __cplusplus +extern "C" { +#endif +#include /* low-level i/o */ +#include +#include +#include +#include +#include +#include + +#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_ \ No newline at end of file diff --git a/server/main.go b/server/main.go index 10e6fc0..1247c18 100644 --- a/server/main.go +++ b/server/main.go @@ -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) + }() +} diff --git a/server/proto/auth.go b/server/proto/auth.go index 4cb6336..cc60f93 100644 --- a/server/proto/auth.go +++ b/server/proto/auth.go @@ -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"` +} diff --git a/server/router/application.go b/server/router/application.go index 1d59e39..e803c5d 100644 --- a/server/router/application.go +++ b/server/router/application.go @@ -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 } diff --git a/server/router/auth.go b/server/router/auth.go index 42585d3..0395afd 100644 --- a/server/router/auth.go +++ b/server/router/auth.go @@ -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 } diff --git a/server/router/stream.go b/server/router/stream.go index 0589cfe..a59308e 100644 --- a/server/router/stream.go +++ b/server/router/stream.go @@ -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 } diff --git a/server/service/application/application.go b/server/service/application/application.go index fded7b3..445dd20 100644 --- a/server/service/application/application.go +++ b/server/service/application/application.go @@ -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 +} diff --git a/server/service/application/lib.go b/server/service/application/lib.go deleted file mode 100644 index e508305..0000000 --- a/server/service/application/lib.go +++ /dev/null @@ -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 -} diff --git a/server/service/application/service.go b/server/service/application/service.go index 46a0376..62ba4f7 100644 --- a/server/service/application/service.go +++ b/server/service/application/service.go @@ -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" ) diff --git a/server/service/auth/auth.go b/server/service/auth/auth.go index afe84ba..b7ae877 100644 --- a/server/service/auth/auth.go +++ b/server/service/auth/auth.go @@ -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") +} diff --git a/server/service/stream/frame-rate.go b/server/service/stream/frame-rate.go new file mode 100644 index 0000000..a4ffed3 --- /dev/null +++ b/server/service/stream/frame-rate.go @@ -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 +} diff --git a/server/service/stream/h264/client.go b/server/service/stream/h264/client.go new file mode 100644 index 0000000..4714ac6 --- /dev/null +++ b/server/service/stream/h264/client.go @@ -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 +} diff --git a/server/service/stream/h264/h264.go b/server/service/stream/h264/h264.go new file mode 100644 index 0000000..f7f571b --- /dev/null +++ b/server/service/stream/h264/h264.go @@ -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() +} diff --git a/server/service/stream/h264/sender.go b/server/service/stream/h264/sender.go new file mode 100644 index 0000000..da16c9c --- /dev/null +++ b/server/service/stream/h264/sender.go @@ -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)) +} diff --git a/server/service/stream/mjpeg.go b/server/service/stream/mjpeg.go deleted file mode 100644 index 415ee66..0000000 --- a/server/service/stream/mjpeg.go +++ /dev/null @@ -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 - } -} diff --git a/server/service/stream/frame-detect.go b/server/service/stream/mjpeg/frame-detect.go similarity index 92% rename from server/service/stream/frame-detect.go rename to server/service/stream/mjpeg/frame-detect.go index 2cf2110..cb4fc8a 100644 --- a/server/service/stream/frame-detect.go +++ b/server/service/stream/mjpeg/frame-detect.go @@ -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) diff --git a/server/service/stream/mjpeg/mjpeg.go b/server/service/stream/mjpeg/mjpeg.go new file mode 100644 index 0000000..501694c --- /dev/null +++ b/server/service/stream/mjpeg/mjpeg.go @@ -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 +} diff --git a/server/service/stream/service.go b/server/service/stream/service.go deleted file mode 100644 index 881e95f..0000000 --- a/server/service/stream/service.go +++ /dev/null @@ -1,7 +0,0 @@ -package stream - -type Service struct{} - -func NewService() *Service { - return &Service{} -} diff --git a/server/service/vm/screen.go b/server/service/vm/screen.go index 68ef750..a42a395 100644 --- a/server/service/vm/screen.go +++ b/server/service/vm/screen.go @@ -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) } diff --git a/server/service/ws/watch.go b/server/service/ws/watch.go deleted file mode 100644 index 0d19704..0000000 --- a/server/service/ws/watch.go +++ /dev/null @@ -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 -} diff --git a/server/service/ws/ws.go b/server/service/ws/ws.go index 55acfc4..25bc010 100644 --- a/server/service/ws/ws.go +++ b/server/service/ws/ws.go @@ -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") diff --git a/web/src/api/application.ts b/web/src/api/application.ts index 1f6b451..4268876 100644 --- a/web/src/api/application.ts +++ b/web/src/api/application.ts @@ -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 - }); -} diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index b5b3fe7..dfe8fa8 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -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'); +} diff --git a/web/src/i18n/locales/cz.ts b/web/src/i18n/locales/cz.ts index b0b0a80..32c05ff 100644 --- a/web/src/i18n/locales/cz.ts +++ b/web/src/i18n/locales/cz.ts @@ -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é', diff --git a/web/src/i18n/locales/da.ts b/web/src/i18n/locales/da.ts index af691bc..6b1d53d 100644 --- a/web/src/i18n/locales/da.ts +++ b/web/src/i18n/locales/da.ts @@ -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', diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index 6316516..563008c 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -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.' diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 3acad27..f5fccb2 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -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." diff --git a/web/src/i18n/locales/es.ts b/web/src/i18n/locales/es.ts index 8cadd13..c2df18a 100644 --- a/web/src/i18n/locales/es.ts +++ b/web/src/i18n/locales/es.ts @@ -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', diff --git a/web/src/i18n/locales/fr.ts b/web/src/i18n/locales/fr.ts index 649b3a9..23c7606 100644 --- a/web/src/i18n/locales/fr.ts +++ b/web/src/i18n/locales/fr.ts @@ -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" diff --git a/web/src/i18n/locales/hu.ts b/web/src/i18n/locales/hu.ts index ff8c8cd..414b3cc 100644 --- a/web/src/i18n/locales/hu.ts +++ b/web/src/i18n/locales/hu.ts @@ -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; diff --git a/web/src/i18n/locales/id.ts b/web/src/i18n/locales/id.ts index 212b680..b6cff5c 100644 --- a/web/src/i18n/locales/id.ts +++ b/web/src/i18n/locales/id.ts @@ -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', diff --git a/web/src/i18n/locales/it.ts b/web/src/i18n/locales/it.ts index ab72f58..9b0f8cb 100644 --- a/web/src/i18n/locales/it.ts +++ b/web/src/i18n/locales/it.ts @@ -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.' diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index 696dbd6..e041973 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -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経由でコンピュータに接続します。', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 43f764d..ddf8e9a 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -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: '프레임 간의 차이를 계산합니다. 원격 호스트 화면에 변경 사항이 감지되지 않으면 비디오 스트림 전송을 중지합니다.' diff --git a/web/src/i18n/locales/nl.ts b/web/src/i18n/locales/nl.ts index ccb318c..02d510c 100644 --- a/web/src/i18n/locales/nl.ts +++ b/web/src/i18n/locales/nl.ts @@ -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', diff --git a/web/src/i18n/locales/pl.ts b/web/src/i18n/locales/pl.ts index 6c54c19..30e3347 100644 --- a/web/src/i18n/locales/pl.ts +++ b/web/src/i18n/locales/pl.ts @@ -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.' diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index b2bdc90..128498d 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -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: 'Вычисляет разницу между кадрами и прекращает передачу видеопотока, если на экране удаленного узла не обнаружено никаких изменений.' diff --git a/web/src/i18n/locales/uk.ts b/web/src/i18n/locales/uk.ts index 16022af..5b469b1 100644 --- a/web/src/i18n/locales/uk.ts +++ b/web/src/i18n/locales/uk.ts @@ -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; diff --git a/web/src/i18n/locales/vi.ts b/web/src/i18n/locales/vi.ts index ddd876b..675808f 100644 --- a/web/src/i18n/locales/vi.ts +++ b/web/src/i18n/locales/vi.ts @@ -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; diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 6438987..5a73bfd 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -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: '计算帧之间的差异,当检测到远程主机画面不变时,停止传输视频流' }, diff --git a/web/src/jotai/screen.ts b/web/src/jotai/screen.ts index d6a2d92..c1bd29d 100644 --- a/web/src/jotai/screen.ts +++ b/web/src/jotai/screen.ts @@ -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(null); diff --git a/web/src/lib/localstorage.ts b/web/src/lib/localstorage.ts index 7df0fa6..215be15 100644 --- a/web/src/lib/localstorage.ts +++ b/web/src/lib/localstorage.ts @@ -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; +} diff --git a/web/src/pages/auth/password/index.tsx b/web/src/pages/auth/password/index.tsx index 08ef535..c20583d 100644 --- a/web/src/pages/auth/password/index.tsx +++ b/web/src/pages/auth/password/index.tsx @@ -58,6 +58,10 @@ export const Password = () => { return !regex.test(str); } + function cancel() { + window.location.replace('/'); + } + return ( <> @@ -105,7 +109,7 @@ export const Password = () => { - diff --git a/web/src/pages/desktop/index.tsx b/web/src/pages/desktop/index.tsx index af1d8eb..7b2b77b 100644 --- a/web/src/pages/desktop/index.tsx +++ b/web/src/pages/desktop/index.tsx @@ -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 = () => { <> - + {isBigScreen && } - - - {resolution && ( + {videoMode && resolution && ( <> {isBigScreen ? : } diff --git a/web/src/pages/desktop/lib.tsx b/web/src/pages/desktop/lib.tsx deleted file mode 100644 index fcf663d..0000000 --- a/web/src/pages/desktop/lib.tsx +++ /dev/null @@ -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}; -}; diff --git a/web/src/pages/desktop/menu-phone/screen/constants.ts b/web/src/pages/desktop/menu-phone/screen/constants.ts new file mode 100644 index 0000000..64d45e7 --- /dev/null +++ b/web/src/pages/desktop/menu-phone/screen/constants.ts @@ -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] +]); diff --git a/web/src/pages/desktop/menu-phone/screen/index.tsx b/web/src/pages/desktop/menu-phone/screen/index.tsx index 1d3c243..035f1b2 100644 --- a/web/src/pages/desktop/menu-phone/screen/index.tsx +++ b/web/src/pages/desktop/menu-phone/screen/index.tsx @@ -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 ( + - - + + {videoMode === 'mjpeg' && } } placement="rightBottom" diff --git a/web/src/pages/desktop/menu-phone/screen/quality.tsx b/web/src/pages/desktop/menu-phone/screen/quality.tsx index 87d331f..b9d823d 100644 --- a/web/src/pages/desktop/menu-phone/screen/quality.tsx +++ b/web/src/pages/desktop/menu-phone/screen/quality.tsx @@ -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) => {
{item.key === quality && }
- {item.label} + {item.label} ))} diff --git a/web/src/pages/desktop/menu-phone/screen/video-mode.tsx b/web/src/pages/desktop/menu-phone/screen/video-mode.tsx new file mode 100644 index 0000000..5c6bfe4 --- /dev/null +++ b/web/src/pages/desktop/menu-phone/screen/video-mode.tsx @@ -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) => ( +
update(mode.key)} + > +
+ {mode.key === videoMode && } +
+ + {mode.name} +
+ ))} + + ); + + return ( + +
+ + {t('screen.video')} +
+
+ ); +}; diff --git a/web/src/pages/desktop/menu/screen/constants.ts b/web/src/pages/desktop/menu/screen/constants.ts new file mode 100644 index 0000000..64d45e7 --- /dev/null +++ b/web/src/pages/desktop/menu/screen/constants.ts @@ -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] +]); diff --git a/web/src/pages/desktop/menu/screen/index.tsx b/web/src/pages/desktop/menu/screen/index.tsx index 78dc899..2dcfb9d 100644 --- a/web/src/pages/desktop/menu/screen/index.tsx +++ b/web/src/pages/desktop/menu/screen/index.tsx @@ -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 ( + - - + + {videoMode === 'mjpeg' && } } placement="bottomLeft" diff --git a/web/src/pages/desktop/menu/screen/quality.tsx b/web/src/pages/desktop/menu/screen/quality.tsx index 87d331f..2309361 100644 --- a/web/src/pages/desktop/menu/screen/quality.tsx +++ b/web/src/pages/desktop/menu/screen/quality.tsx @@ -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) => {
{item.key === quality && }
- {item.label} + {item.label} ))} diff --git a/web/src/pages/desktop/menu/screen/video-mode.tsx b/web/src/pages/desktop/menu/screen/video-mode.tsx new file mode 100644 index 0000000..5c6bfe4 --- /dev/null +++ b/web/src/pages/desktop/menu/screen/video-mode.tsx @@ -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) => ( +
update(mode.key)} + > +
+ {mode.key === videoMode && } +
+ + {mode.name} +
+ ))} + + ); + + return ( + +
+ + {t('screen.video')} +
+
+ ); +}; diff --git a/web/src/pages/desktop/mouse/absolute.tsx b/web/src/pages/desktop/mouse/absolute.tsx index 2ce3256..a615068 100644 --- a/web/src/pages/desktop/mouse/absolute.tsx +++ b/web/src/pages/desktop/mouse/absolute.tsx @@ -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) { diff --git a/web/src/pages/desktop/mouse/relative.tsx b/web/src/pages/desktop/mouse/relative.tsx index 7556bb8..bb8fba5 100644 --- a/web/src/pages/desktop/mouse/relative.tsx +++ b/web/src/pages/desktop/mouse/relative.tsx @@ -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) { diff --git a/web/src/pages/desktop/notification.tsx b/web/src/pages/desktop/notification.tsx new file mode 100644 index 0000000..e2741c1 --- /dev/null +++ b/web/src/pages/desktop/notification.tsx @@ -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: , + duration: null, + onClose: () => setSkipModifyPassword(true) + }); + } + + function changePassword() { + api.destroy(); + navigate('/auth/password'); + } + + return <>{contextHolder}; +}; diff --git a/web/src/pages/desktop/screen/h264.tsx b/web/src/pages/desktop/screen/h264.tsx new file mode 100644 index 0000000..66f6400 --- /dev/null +++ b/web/src/pages/desktop/screen/h264.tsx @@ -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 && } size="large" fullscreen />} + +
+
+ + ); +}; diff --git a/web/src/pages/desktop/screen/index.tsx b/web/src/pages/desktop/screen/index.tsx index 31e4dba..b662a3c 100644 --- a/web/src/pages/desktop/screen/index.tsx +++ b/web/src/pages/desktop/screen/index.tsx @@ -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 ( -
- -
- ); + return <>{videoMode === 'mjpeg' ? : }; }; diff --git a/web/src/pages/desktop/screen/mjpeg.tsx b/web/src/pages/desktop/screen/mjpeg.tsx new file mode 100644 index 0000000..2ac9edb --- /dev/null +++ b/web/src/pages/desktop/screen/mjpeg.tsx @@ -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 ( +
+ +
+ ); +};