support h264

This commit is contained in:
wj-xiao
2024-11-06 18:43:16 +08:00
parent b7afc27f44
commit 74a303bd5c
66 changed files with 1815 additions and 799 deletions

View File

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

View File

@@ -25,13 +25,6 @@ server
## 部署
```shell
# 编译
cd server
go mod tidy
GOARCH=riscv64 GOOS=linux go build
```
待完善。
1. 编译完成后会生成可执行文件 `NanoKVM-Server`
2.`NanoKVM-Server` 文件上传到 NanoKVM 的 `/kvmapp/server/` 目录下;
3. 在 NanoKVM 中执行 `/etc/init.d/S95nanokvm restart` 重启服务。
项目中使用了 CGO需要使用 Linux 并安装工具链后才能编译。这部分内容会晚一点更新。

148
server/common/cgo.go Normal file
View File

@@ -0,0 +1,148 @@
package common
/*
#cgo CFLAGS: -I../include
#cgo LDFLAGS: -L../dl_lib -lkvm
#include "kvm_vision.h"
*/
import "C"
import (
"NanoKVM-Server/config"
"strings"
"sync"
"unsafe"
log "github.com/sirupsen/logrus"
)
var (
kvmVision *KvmVision
kvmVisionOnce sync.Once
)
type KvmVision struct {
mutex sync.Mutex
}
func GetKvmVision() *KvmVision {
kvmVisionOnce.Do(func() {
kvmVision = &KvmVision{}
conf := config.GetInstance()
logLevel := strings.ToLower(conf.Logger.Level)
logEnable := C.uint8_t(0)
if logLevel == "debug" {
logEnable = C.uint8_t(1)
}
C.kvmv_init(logEnable)
log.Debugf("kvm vision initialized")
})
return kvmVision
}
func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int) {
k.mutex.Lock()
defer k.mutex.Unlock()
var (
kvmData *C.uint8_t
dataSize C.uint32_t
)
result = int(C.kvmv_read_img(
C.uint16_t(width),
C.uint16_t(height),
C.uint8_t(0),
C.uint16_t(quality),
&kvmData,
&dataSize,
))
if result < 0 {
log.Errorf("failed to read kvm image: %v", result)
return
}
defer C.free_kvmv_data(&kvmData)
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
log.Debugf("read kvm image: %v", result)
return
}
func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data []byte, sps []byte, pps []byte, result int) {
k.mutex.Lock()
defer k.mutex.Unlock()
var (
kvmData *C.uint8_t
dataSize C.uint32_t
)
result = int(C.kvmv_read_img(
C.uint16_t(width),
C.uint16_t(height),
C.uint8_t(1),
C.uint16_t(bitRate),
&kvmData,
&dataSize,
))
if result < 0 {
log.Errorf("failed to read kvm image: %v", result)
return
}
defer C.free_kvmv_data(&kvmData)
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
if result == 3 {
sps, _ = k.ReadH264SPS()
pps, _ = k.ReadH264PPS()
}
log.Debugf("read kvm image: %v", result)
return
}
func (k *KvmVision) ReadH264SPS() ([]byte, int) {
var (
kvmData *C.uint8_t
dataSize C.uint32_t
)
result := C.kvmv_get_sps_frame(&kvmData, &dataSize)
if result < 0 {
log.Errorf("failed to read sps: %v", result)
return nil, int(result)
}
data := C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
return data, int(result)
}
func (k *KvmVision) ReadH264PPS() ([]byte, int) {
var (
kvmData *C.uint8_t
dataSize C.uint32_t
)
result := C.kvmv_get_pps_frame(&kvmData, &dataSize)
if result < 0 {
log.Errorf("failed to read pps: %v", result)
return nil, int(result)
}
data := C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
return data, int(result)
}
func (k *KvmVision) Close() {
k.mutex.Lock()
defer k.mutex.Unlock()
C.kvmv_deinit()
log.Debugf("stop kvm vision...")
}

85
server/common/screen.go Normal file
View File

@@ -0,0 +1,85 @@
package common
import "sync"
type Screen struct {
Width uint16
Height uint16
FPS int
Quality uint16
BitRate uint16
}
var (
screen *Screen
screenOnce sync.Once
)
// ResolutionMap height to width
var ResolutionMap = map[uint16]uint16{
1080: 1920,
720: 1280,
600: 800,
480: 640,
0: 0,
}
var QualityMap = map[uint16]bool{
100: true,
80: true,
60: true,
50: true,
}
var BitRateMap = map[uint16]bool{
5000: true,
3000: true,
2000: true,
1000: true,
}
func GetScreen() *Screen {
screenOnce.Do(func() {
screen = &Screen{
Width: 0,
Height: 0,
Quality: 80,
FPS: 30,
BitRate: 3000,
}
})
return screen
}
func SetScreen(key string, value int) {
switch key {
case "resolution":
height := uint16(value)
if width, ok := ResolutionMap[height]; ok {
screen.Width = width
screen.Height = height
}
case "quality":
if value > 100 {
screen.BitRate = uint16(value)
} else {
screen.Quality = uint16(value)
}
case "fps":
screen.FPS = validateFPS(value)
}
}
func validateFPS(fps int) int {
if fps > 60 {
return 60
}
if fps < 10 {
return 10
}
return fps
}

View File

@@ -18,23 +18,6 @@ import (
var (
config Config
once sync.Once
defaultConfig = &Config{
Protocol: "http",
Port: Port{
Http: 80,
Https: 443,
},
Cert: Cert{
Crt: "server.crt",
Key: "server.key",
},
Logger: Logger{
Level: "info",
File: "stdout",
},
Authentication: "enable",
}
)
func GetInstance() *Config {

18
server/config/default.go Normal file
View File

@@ -0,0 +1,18 @@
package config
var defaultConfig = &Config{
Protocol: "http",
Port: Port{
Http: 80,
Https: 443,
},
Cert: Cert{
Crt: "server.crt",
Key: "server.key",
},
Logger: Logger{
Level: "info",
File: "stdout",
},
Authentication: "enable",
}

BIN
server/dl_lib/libkvm.so Normal file

Binary file not shown.

View File

@@ -0,0 +1,59 @@
#ifndef KVM_VISION_H_
#define KVM_VISION_H_
#ifdef __cplusplus
extern "C" {
#endif
#include <fcntl.h> /* low-level i/o */
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <pthread.h>
#define IMG_BUFFER_FULL -3
#define IMG_VENC_ERROR -2
#define IMG_NOT_EXIST -1
#define IMG_MJPEG_TYPE 0
#define IMG_H264_TYPE_SPS 1
#define IMG_H264_TYPE_PPS 2
#define IMG_H264_TYPE_IF 3
#define IMG_H264_TYPE_PF 4
void kvmv_init(uint8_t _debug_info_en);
/**********************************************************************************
* @name kvmv_read_img
* @author Sipeed BuGu
* @date 2024/10/25
* @version R1.0
* @brief Acquire the encoded image with auto init
* @param _width @input: Output image width
* @param _height @input: Output image height
* @param _type @input: Encode type
* @param _qlty @input: MJPEG: (50-100) | H264: (500-10000)
* @param _pp_kvm_data @output: Encode data
* @param _p_kvmv_data_size @output: Encode data size
* @return
-3: img buffer full
-2: VENC Error
-1: No images were acquired
0: Acquire MJPEG encoded images
1: Acquire H264 encoded images(SPS)
2: Acquire H264 encoded images(PPS)
3: Acquire H264 encoded images(I)
4: Acquire H264 encoded images(P)
**********************************************************************************/
int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _qlty, uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size);
int kvmv_get_sps_frame(uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size);
int kvmv_get_pps_frame(uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size);
int free_kvmv_data(uint8_t ** _pp_kvm_data);
void free_all_kvmv_data();
void set_h264_gop(uint8_t _gop);
void kvmv_deinit();
#ifdef __cplusplus
}
#endif
#endif // KVM_VISION_H_

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
package stream
import (
"fmt"
"os"
"sync"
"sync/atomic"
"time"
log "github.com/sirupsen/logrus"
)
var (
counter *FrameRateCounter
counterOnce sync.Once
)
type FrameRateCounter struct {
frameCount int32
fps int32
mutex sync.Mutex
}
func GetFrameRateCounter() *FrameRateCounter {
counterOnce.Do(func() {
counter = &FrameRateCounter{}
go func() {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for range ticker.C {
counter.mutex.Lock()
currentCount := atomic.LoadInt32(&counter.frameCount)
counter.fps = currentCount / 3
atomic.StoreInt32(&counter.frameCount, 0)
counter.mutex.Unlock()
data := fmt.Sprintf("%d", counter.fps)
err := os.WriteFile("/kvmapp/kvm/now_fps", []byte(data), 0o666)
if err != nil {
log.Errorf("failed to wirte fps: %s", err)
}
}
}()
})
return counter
}
func (f *FrameRateCounter) Update() {
atomic.AddInt32(&f.frameCount, 1)
}
func (f *FrameRateCounter) GetFPS() int32 {
f.mutex.Lock()
defer f.mutex.Unlock()
return f.fps
}

View File

@@ -0,0 +1,173 @@
package h264
import (
"encoding/json"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
type Client struct {
ws *websocket.Conn
pc *webrtc.PeerConnection
}
type Message struct {
Event string `json:"event"`
Data string `json:"data"`
}
// add video track
func (c *Client) addTrack() {
videoTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
"video",
"pion",
)
if err != nil {
log.Errorf("failed to create video track: %s", err)
return
}
_, err = c.pc.AddTrack(videoTrack)
if err != nil {
log.Errorf("failed to add video track: %s", err)
return
}
mutex.Lock()
trackMap[c.ws] = videoTrack
mutex.Unlock()
}
// register callback events
func (c *Client) register() {
// new ICE candidate found
c.pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate == nil {
return
}
candidateByte, err := json.Marshal(candidate.ToJSON())
if err != nil {
log.Errorf("failed to marshal candidate: %s", err)
return
}
_ = c.sendMessage("candidate", string(candidateByte))
})
// ICE connection state has changed
c.pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
if state == webrtc.ICEConnectionStateConnected && !isSending {
mutex.Lock()
if !isSending {
// start sending h264 data
go send()
isSending = true
}
mutex.Unlock()
}
log.Debugf("ice connection state has changed to %s", state.String())
})
}
// read websocket message
func (c *Client) readMessage() {
message := &Message{}
for {
_, raw, err := c.ws.ReadMessage()
if err != nil {
mutex.Lock()
delete(trackMap, c.ws)
if len(trackMap) == 0 && isSending {
// stop sending when all websocket connections are closed
exitSig <- true
}
mutex.Unlock()
log.Debugf("failed to read message: %s", err)
return
}
if err := json.Unmarshal(raw, &message); err != nil {
log.Errorf("failed to unmarshal message: %s", err)
continue
}
log.Debugf("receive message event: %s", message.Event)
switch message.Event {
case "offer":
offer := webrtc.SessionDescription{}
if err := json.Unmarshal([]byte(message.Data), &offer); err != nil {
log.Errorf("failed to unmarshal offer message: %s", err)
return
}
if err := c.pc.SetRemoteDescription(offer); err != nil {
log.Errorf("failed to set remote description: %s", err)
return
}
answer, answerErr := c.pc.CreateAnswer(nil)
if answerErr != nil {
log.Errorf("failed to create answer: %s", answerErr)
return
}
if err := c.pc.SetLocalDescription(answer); err != nil {
log.Errorf("failed to set local description: %s", err)
return
}
answerByte, answerByteErr := json.Marshal(answer)
if answerByteErr != nil {
log.Errorf("failed to marshal answer: %s", answerByteErr)
return
}
_ = c.sendMessage("answer", string(answerByte))
case "candidate":
candidate := webrtc.ICECandidateInit{}
if err := json.Unmarshal([]byte(message.Data), &candidate); err != nil {
log.Errorf("failed to unmarshal candidate message: %s", err)
return
}
if err := c.pc.AddICECandidate(candidate); err != nil {
log.Errorf("failed to add ICE candidate: %s", err)
return
}
case "heartbeat":
_ = c.sendMessage("heartbeat", "")
default:
log.Debugf("unhandled message event: %s", message.Event)
}
}
}
// send websocket message
func (c *Client) sendMessage(event string, data string) error {
mutex.RLock()
defer mutex.RUnlock()
message := &Message{
Event: event,
Data: data,
}
if err := c.ws.WriteJSON(message); err != nil {
log.Errorf("failed to send message %s: %s", event, err)
return err
}
log.Debugf("send message %s", message.Event)
return nil
}

View File

@@ -0,0 +1,64 @@
package h264
import (
"net/http"
"sync"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
var (
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
trackMap = make(map[*websocket.Conn]*webrtc.TrackLocalStaticSample)
mutex = sync.RWMutex{}
isSending = false
exitSig = make(chan bool, 1)
)
func Connect(c *gin.Context) {
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("failed to create websocket: %s", err)
return
}
defer func() {
_ = wsConn.Close()
log.Debugf("h264 websocket disconnected")
}()
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
peerConn, err := webrtc.NewPeerConnection(config)
if err != nil {
log.Errorf("failed to create PeerConnection: %s", err)
return
}
defer func() {
_ = peerConn.Close()
log.Debugf("PeerConnection disconnected")
}()
client := &Client{
ws: wsConn,
pc: peerConn,
}
client.addTrack()
client.register()
client.readMessage()
}

View File

@@ -0,0 +1,80 @@
package h264
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"time"
"github.com/pion/webrtc/v4/pkg/media"
log "github.com/sirupsen/logrus"
)
func send() {
screen := common.GetScreen()
fps := screen.FPS
duration := time.Second / time.Duration(fps)
ticker := time.NewTicker(duration)
defer ticker.Stop()
vision := common.GetKvmVision()
for {
select {
case <-ticker.C:
height := screen.Height
width, ok := common.ResolutionMap[height]
if !ok {
width = 0
height = 0
}
bitRate := screen.BitRate
if _, ok := common.BitRateMap[bitRate]; !ok {
bitRate = 3000
}
data, sps, pps, result := vision.ReadH264(width, height, bitRate)
if result < 0 {
continue
}
if result == 3 {
writeSample(sps, duration)
writeSample(pps, duration)
}
writeSample(data, duration)
stream.GetFrameRateCounter().Update()
if screen.FPS != fps {
fps = screen.FPS
duration = time.Second / time.Duration(fps)
ticker.Reset(duration)
}
case <-exitSig:
mutex.Lock()
isSending = false
mutex.Unlock()
return
}
}
}
func writeSample(data []byte, duration time.Duration) {
sample := media.Sample{
Data: data,
Duration: duration,
}
mutex.RLock()
defer mutex.RUnlock()
for _, track := range trackMap {
if err := track.WriteSample(sample); err != nil {
log.Errorf("failed to send h264 data: %s", err)
}
}
log.Debugf("send h264 data: %d", len(data))
}

View File

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

View File

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

View File

@@ -0,0 +1,111 @@
package mjpeg
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"fmt"
"sync"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
var (
chanMap = make(map[*gin.Context]chan struct{})
mutex = sync.RWMutex{}
exitSig = make(chan bool, 1)
)
func Connect(c *gin.Context) {
c.Header("Content-Type", fmt.Sprintf("multipart/x-mixed-replace; boundary=frame"))
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Pragma", "no-cache")
mutex.Lock()
chanMap[c] = make(chan struct{}, 1)
if len(chanMap) == 1 {
go send()
}
mutex.Unlock()
<-chanMap[c]
mutex.Lock()
delete(chanMap, c)
if len(chanMap) == 0 {
exitSig <- true
}
mutex.Unlock()
}
func send() {
screen := common.GetScreen()
fps := screen.FPS
ticker := time.NewTicker(time.Second / time.Duration(fps))
defer ticker.Stop()
vision := common.GetKvmVision()
for {
select {
case <-ticker.C:
height := screen.Height
width, ok := common.ResolutionMap[height]
if !ok {
width = 0
height = 0
}
quality := screen.Quality
if _, ok := common.QualityMap[quality]; !ok {
quality = 80
}
data, result := vision.ReadMjpeg(width, height, quality)
if result < 0 {
continue
}
for c, ch := range chanMap {
if err := write(c, data); err != nil {
log.Debugf("failed to write mjpeg data: %s", err)
close(ch)
}
}
log.Debugf("send mjpeg data: %d", len(data))
stream.GetFrameRateCounter().Update()
if screen.FPS != fps {
fps = screen.FPS
ticker.Reset(time.Second / time.Duration(fps))
}
case <-exitSig:
return
}
}
}
func write(c *gin.Context, data []byte) (err error) {
if _, err = c.Writer.Write([]byte("--frame\r\n")); err != nil {
return
}
if _, err = c.Writer.Write([]byte("Content-Type: image/jpeg\r\n\r\n")); err != nil {
return
}
if _, err = c.Writer.Write(data); err != nil {
return
}
if _, err = c.Writer.Write([]byte("\r\n")); err != nil {
return
}
c.Writer.Flush()
return
}

View File

@@ -1,7 +0,0 @@
package stream
type Service struct{}
func NewService() *Service {
return &Service{}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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経由でコンピュータに接続します。',

View File

@@ -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:
'프레임 간의 차이를 계산합니다. 원격 호스트 화면에 변경 사항이 감지되지 않으면 비디오 스트림 전송을 중지합니다.'

View File

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

View File

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

View File

@@ -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:
'Вычисляет разницу между кадрами и прекращает передачу видеопотока, если на экране удаленного узла не обнаружено никаких изменений.'

View File

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

View File

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

View File

@@ -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: '计算帧之间的差异,当检测到远程主机画面不变时,停止传输视频流'
},

View File

@@ -2,8 +2,8 @@ import { atom } from 'jotai';
import { Resolution } from '@/types';
// mjpeg stream url
export const streamUrlAtom = atom('');
// video mode: h264 or mjpeg
export const videoModeAtom = atom('');
// browser screen resolution
export const resolutionAtom = atom<Resolution | null>(null);

View File

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

View File

@@ -58,6 +58,10 @@ export const Password = () => {
return !regex.test(str);
}
function cancel() {
window.location.replace('/');
}
return (
<>
<Head title={t('head.changePassword')} />
@@ -105,7 +109,7 @@ export const Password = () => {
<Button type="primary" htmlType="submit" className="w-1/2">
{t('auth.ok')}
</Button>
<Button className="w-1/2" onClick={() => navigate('/')}>
<Button className="w-1/2" onClick={cancel}>
{t('auth.cancel')}
</Button>
</div>

View File

@@ -1,57 +1,40 @@
import { useEffect, useState } from 'react';
import { Spin } from 'antd';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useEffect } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { useMediaQuery } from 'react-responsive';
import { getResolution } from '@/lib/localstorage.ts';
import { getBaseUrl } from '@/lib/service.ts';
import { getResolution, getVideoMode } from '@/lib/localstorage.ts';
import { client } from '@/lib/websocket.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { resolutionAtom, streamUrlAtom } from '@/jotai/screen.ts';
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
import { Head } from '@/components/head.tsx';
import { Keyboard } from './keyboard';
import { VirtualKeyboard } from './keyboard/virtual-keyboard';
import { Lib } from './lib.tsx';
import { Menu } from './menu';
import { MenuPhone } from './menu-phone';
import { Mouse } from './mouse';
import { Notification } from './notification.tsx';
import { Screen } from './screen';
export const Desktop = () => {
const { t } = useTranslation();
const isBigScreen = useMediaQuery({ minWidth: 850 });
const [videoMode, setVideoMode] = useAtom(videoModeAtom);
const [resolution, setResolution] = useAtom(resolutionAtom);
const setStreamUrl = useSetAtom(streamUrlAtom);
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
const [isLoading, setIsLoading] = useState(false);
const [tips, setTips] = useState('');
useEffect(() => {
const cookieVideoMode = getVideoMode();
setVideoMode(cookieVideoMode ? cookieVideoMode : window.RTCPeerConnection ? 'h264' : 'mjpeg');
const cookieResolution = getResolution();
setResolution(cookieResolution ? cookieResolution : { width: 0, height: 0 });
setStreamUrl(`${getBaseUrl('http')}/api/stream/mjpeg`);
const timer = setInterval(() => {
client.send([0]);
}, 1000 * 60);
client.register('stream', (message) => {
const data = JSON.parse(message.data as string);
if (data.state === 0) {
setIsLoading(true);
setTimeout(() => setIsLoading(false), 5000);
} else {
setIsLoading(false);
const now = Date.now();
setStreamUrl(`${getBaseUrl('http')}/api/stream/mjpeg?n=${now}`);
}
});
}, 60 * 1000);
return () => {
clearInterval(timer);
@@ -64,11 +47,9 @@ export const Desktop = () => {
<>
<Head title={t('head.desktop')} />
<Spin spinning={isLoading} tip={tips} size="large" fullscreen />
{isBigScreen && <Notification />}
<Lib setIsLoading={setIsLoading} setTips={setTips} />
{resolution && (
{videoMode && resolution && (
<>
{isBigScreen ? <Menu /> : <MenuPhone />}

View File

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

View File

@@ -0,0 +1,13 @@
export const QualityMap = new Map([
[1, 100],
[2, 80],
[3, 60],
[4, 50]
]);
export const BitRateMap = new Map([
[1, 5000],
[2, 3000],
[3, 2000],
[4, 1000]
]);

View File

@@ -5,48 +5,63 @@ import { MonitorIcon } from 'lucide-react';
import { updateScreen } from '@/api/vm';
import * as ls from '@/lib/localstorage';
import { resolutionAtom } from '@/jotai/screen.ts';
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
import { BitRateMap, QualityMap } from './constants.ts';
import { Fps } from './fps';
import { FrameDetect } from './frame-detect';
import { Quality } from './quality';
import { Resolution } from './resolution';
import { VideoMode } from './video-mode.tsx';
export const Screen = () => {
const videoMode = useAtomValue(videoModeAtom);
const resolution = useAtomValue(resolutionAtom);
const [fps, setFps] = useState(30);
const [quality, setQuality] = useState(80);
useEffect(() => {
updateScreen('type', videoMode === 'mjpeg' ? 0 : 1);
updateScreen('resolution', resolution!.height);
const cookieFps = ls.getFps();
if (cookieFps) {
updateScreen('fps', cookieFps).then((rsp) => {
if (rsp.code === 0) {
setFps(cookieFps);
}
});
}
const cookieQuality = ls.getQuality();
if (cookieQuality) {
updateScreen('quality', cookieQuality).then((rsp) => {
if (rsp.code === 0) {
setQuality(cookieQuality);
}
});
}
updateQuality();
updateFps();
}, []);
function updateQuality() {
const cookieQuality = ls.getQuality();
if (!cookieQuality) return;
const key = cookieQuality >= 1 && cookieQuality <= 4 ? cookieQuality : 2;
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!;
updateScreen('quality', value).then((rsp) => {
if (rsp.code === 0) {
setQuality(key);
}
});
}
function updateFps() {
const cookieFps = ls.getFps();
if (!cookieFps) return;
updateScreen('fps', cookieFps).then((rsp) => {
if (rsp.code === 0) {
setFps(cookieFps);
}
});
}
return (
<Popover
content={
<div className="flex flex-col space-y-1">
<VideoMode />
<Resolution />
<Fps fps={fps} setFps={setFps} />
<Quality quality={quality} setQuality={setQuality} />
<FrameDetect />
<Fps fps={fps} setFps={setFps} />
{videoMode === 'mjpeg' && <FrameDetect />}
</div>
}
placement="rightBottom"

View File

@@ -1,18 +1,13 @@
import { Popover } from 'antd';
import { useAtomValue } from 'jotai/index';
import { CheckIcon, SquareActivityIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { setQuality as setCookie } from '@/lib/localstorage.ts';
import { videoModeAtom } from '@/jotai/screen.ts';
const qualityList = [
{ key: 99, label: '100%' },
{ key: 90, label: '90%' },
{ key: 80, label: '80%' },
{ key: 70, label: '70%' },
{ key: 60, label: '60%' },
{ key: 51, label: '50%' }
];
import { BitRateMap, QualityMap } from './constants.ts';
type QualityProps = {
quality: number;
@@ -21,16 +16,25 @@ type QualityProps = {
export const Quality = ({ quality, setQuality }: QualityProps) => {
const { t } = useTranslation();
const videoMode = useAtomValue(videoModeAtom);
const qualityList = [
{ key: 1, label: t('screen.qualityLossless') },
{ key: 2, label: t('screen.qualityHigh') },
{ key: 3, label: t('screen.qualityMedium') },
{ key: 4, label: t('screen.qualityLow') }
];
async function update(key: number) {
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!;
async function update(value: number) {
const rsp = await updateScreen('quality', value);
if (rsp.code !== 0) {
return;
}
setQuality(value);
setCookie(value);
setQuality(key);
setCookie(key);
}
const content = (
@@ -44,7 +48,7 @@ export const Quality = ({ quality, setQuality }: QualityProps) => {
<div className="flex h-[14px] w-[20px] items-end">
{item.key === quality && <CheckIcon size={14} />}
</div>
<span className="flex w-[32px]">{item.label}</span>
<span className="flex w-[40px]">{item.label}</span>
</div>
))}
</>

View File

@@ -0,0 +1,55 @@
import { Popover } from 'antd';
import { useAtomValue } from 'jotai';
import { CheckIcon, TvMinimalPlayIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { setVideoMode as setCookie } from '@/lib/localstorage.ts';
import { videoModeAtom } from '@/jotai/screen.ts';
const videoModes = [
{ key: 'h264', name: 'H.264' },
{ key: 'mjpeg', name: 'MJPEG' }
];
export const VideoMode = () => {
const { t } = useTranslation();
const videoMode = useAtomValue(videoModeAtom);
function update(mode: string) {
if (mode === videoMode) return;
setCookie(mode);
// reload after changing video mode
setTimeout(() => {
window.location.reload();
}, 500);
}
const content = (
<>
{videoModes.map((mode) => (
<div
key={mode.key}
className="flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 pr-5 hover:bg-neutral-600"
onClick={() => update(mode.key)}
>
<div className="flex h-[14px] w-[20px] items-end">
{mode.key === videoMode && <CheckIcon size={15} />}
</div>
<span className="flex w-[50px]">{mode.name}</span>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<TvMinimalPlayIcon size={18} />
<span className="select-none text-sm">{t('screen.video')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,13 @@
export const QualityMap = new Map([
[1, 100],
[2, 80],
[3, 60],
[4, 50]
]);
export const BitRateMap = new Map([
[1, 5000],
[2, 3000],
[3, 2000],
[4, 1000]
]);

View File

@@ -5,48 +5,63 @@ import { MonitorIcon } from 'lucide-react';
import { updateScreen } from '@/api/vm';
import * as ls from '@/lib/localstorage';
import { resolutionAtom } from '@/jotai/screen.ts';
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
import { BitRateMap, QualityMap } from './constants.ts';
import { Fps } from './fps';
import { FrameDetect } from './frame-detect';
import { Quality } from './quality';
import { Resolution } from './resolution';
import { VideoMode } from './video-mode.tsx';
export const Screen = () => {
const videoMode = useAtomValue(videoModeAtom);
const resolution = useAtomValue(resolutionAtom);
const [fps, setFps] = useState(30);
const [quality, setQuality] = useState(80);
useEffect(() => {
updateScreen('type', videoMode === 'mjpeg' ? 0 : 1);
updateScreen('resolution', resolution!.height);
const cookieFps = ls.getFps();
if (cookieFps) {
updateScreen('fps', cookieFps).then((rsp) => {
if (rsp.code === 0) {
setFps(cookieFps);
}
});
}
const cookieQuality = ls.getQuality();
if (cookieQuality) {
updateScreen('quality', cookieQuality).then((rsp) => {
if (rsp.code === 0) {
setQuality(cookieQuality);
}
});
}
updateQuality();
updateFps();
}, []);
function updateQuality() {
const cookieQuality = ls.getQuality();
if (!cookieQuality) return;
const key = cookieQuality >= 1 && cookieQuality <= 4 ? cookieQuality : 2;
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!;
updateScreen('quality', value).then((rsp) => {
if (rsp.code === 0) {
setQuality(key);
}
});
}
function updateFps() {
const cookieFps = ls.getFps();
if (!cookieFps) return;
updateScreen('fps', cookieFps).then((rsp) => {
if (rsp.code === 0) {
setFps(cookieFps);
}
});
}
return (
<Popover
content={
<div className="flex flex-col space-y-1">
<VideoMode />
<Resolution />
<Fps fps={fps} setFps={setFps} />
<Quality quality={quality} setQuality={setQuality} />
<FrameDetect />
<Fps fps={fps} setFps={setFps} />
{videoMode === 'mjpeg' && <FrameDetect />}
</div>
}
placement="bottomLeft"

View File

@@ -1,18 +1,13 @@
import { Popover } from 'antd';
import { useAtomValue } from 'jotai';
import { CheckIcon, SquareActivityIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { setQuality as setCookie } from '@/lib/localstorage.ts';
import { videoModeAtom } from '@/jotai/screen.ts';
const qualityList = [
{ key: 99, label: '100%' },
{ key: 90, label: '90%' },
{ key: 80, label: '80%' },
{ key: 70, label: '70%' },
{ key: 60, label: '60%' },
{ key: 51, label: '50%' }
];
import { BitRateMap, QualityMap } from './constants.ts';
type QualityProps = {
quality: number;
@@ -21,16 +16,25 @@ type QualityProps = {
export const Quality = ({ quality, setQuality }: QualityProps) => {
const { t } = useTranslation();
const videoMode = useAtomValue(videoModeAtom);
const qualityList = [
{ key: 1, label: t('screen.qualityLossless') },
{ key: 2, label: t('screen.qualityHigh') },
{ key: 3, label: t('screen.qualityMedium') },
{ key: 4, label: t('screen.qualityLow') }
];
async function update(key: number) {
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!;
async function update(value: number) {
const rsp = await updateScreen('quality', value);
if (rsp.code !== 0) {
return;
}
setQuality(value);
setCookie(value);
setQuality(key);
setCookie(key);
}
const content = (
@@ -44,7 +48,7 @@ export const Quality = ({ quality, setQuality }: QualityProps) => {
<div className="flex h-[14px] w-[20px] items-end">
{item.key === quality && <CheckIcon size={14} />}
</div>
<span className="flex w-[32px]">{item.label}</span>
<span className="flex w-[50px]">{item.label}</span>
</div>
))}
</>

View File

@@ -0,0 +1,55 @@
import { Popover } from 'antd';
import { useAtomValue } from 'jotai';
import { CheckIcon, TvMinimalPlayIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { setVideoMode as setCookie } from '@/lib/localstorage.ts';
import { videoModeAtom } from '@/jotai/screen.ts';
const videoModes = [
{ key: 'h264', name: 'H.264' },
{ key: 'mjpeg', name: 'MJPEG' }
];
export const VideoMode = () => {
const { t } = useTranslation();
const videoMode = useAtomValue(videoModeAtom);
function update(mode: string) {
if (mode === videoMode) return;
setCookie(mode);
// reload after changing video mode
setTimeout(() => {
window.location.reload();
}, 500);
}
const content = (
<>
{videoModes.map((mode) => (
<div
key={mode.key}
className="flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 pr-5 hover:bg-neutral-600"
onClick={() => update(mode.key)}
>
<div className="flex h-[14px] w-[20px] items-end">
{mode.key === videoMode && <CheckIcon size={15} />}
</div>
<span className="flex w-[50px]">{mode.name}</span>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<TvMinimalPlayIcon size={18} />
<span className="select-none text-sm">{t('screen.video')}</span>
</div>
</Popover>
);
};

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { Button, notification } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { isPasswordUpdated } from '@/api/auth.ts';
import { getSkipModifyPassword, setSkipModifyPassword } from '@/lib/localstorage.ts';
export const Notification = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [api, contextHolder] = notification.useNotification();
useEffect(() => {
const skip = getSkipModifyPassword();
if (skip) return;
isPasswordUpdated().then((rsp) => {
if (rsp.code === 0 && !rsp.data.isUpdated) {
openNotification();
}
});
}, []);
function openNotification() {
api.info({
message: t('auth.changePassword'),
description: t('auth.changePasswordDesc'),
placement: 'topRight',
btn: <Button onClick={changePassword}>{t('auth.ok')}</Button>,
duration: null,
onClose: () => setSkipModifyPassword(true)
});
}
function changePassword() {
api.destroy();
navigate('/auth/password');
}
return <>{contextHolder}</>;
};

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Spin } from 'antd';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { w3cwebsocket as W3cWebSocket } from 'websocket';
import { getBaseUrl } from '@/lib/service.ts';
import { mouseStyleAtom } from '@/jotai/mouse.ts';
import { resolutionAtom } from '@/jotai/screen.ts';
export const H264 = () => {
const resolution = useAtomValue(resolutionAtom);
const mouseStyle = useAtomValue(mouseStyleAtom);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let heartbeatTimer: any;
const videoElement = document.getElementById('screen') as HTMLVideoElement;
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
pc.ontrack = function (event) {
if (event.track.kind !== 'video') {
console.log('unhandled track kind: ', event.track.kind);
return;
}
videoElement.srcObject = event.streams[0];
};
const url = `${getBaseUrl('ws')}/api/stream/h264`;
const ws = new W3cWebSocket(url);
ws.onopen = () => {
pc.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({ event: 'candidate', data: JSON.stringify(event.candidate) }));
}
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.createOffer({ offerToReceiveVideo: true })
.then((offer) => {
pc.setLocalDescription(offer).catch(console.log);
ws.send(JSON.stringify({ event: 'offer', data: JSON.stringify(offer) }));
})
.catch(console.log);
heartbeatTimer = setInterval(() => {
ws.send(JSON.stringify({ event: 'heartbeat', data: '' }));
}, 60 * 1000);
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data as string);
if (!msg) return;
const data = JSON.parse(msg.data);
if (!data) return;
switch (msg.event) {
case 'answer':
pc.setRemoteDescription(data).catch(console.log);
break;
case 'candidate':
pc.addIceCandidate(data).catch(console.log);
break;
case 'heartbeat':
break;
default:
console.log('unhandled event: ', msg.event);
}
};
setTimeout(() => {
setIsLoading(false);
}, 10 * 1000);
return () => {
heartbeatTimer && clearInterval(heartbeatTimer);
ws.close();
pc.close();
};
}, []);
return (
<>
{isLoading && <Spin indicator={<LoadingOutlined spin />} size="large" fullscreen />}
<div className={clsx('flex h-screen w-screen items-start justify-center xl:items-center')}>
<video
id="screen"
className={clsx('block select-none bg-neutral-950', mouseStyle)}
style={
resolution?.width
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }
: { maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }
}
muted
autoPlay
playsInline
controls={false}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onPlaying={() => {
setIsLoading(false);
}}
/>
</div>
</>
);
};

View File

@@ -1,37 +1,12 @@
import { useEffect } from 'react';
import { Image } from 'antd';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import MonitorXIcon from '@/assets/images/monitor-x.svg';
import { stopFrameDetect } from '@/api/stream.ts';
import { mouseStyleAtom } from '@/jotai/mouse.ts';
import { resolutionAtom, streamUrlAtom } from '@/jotai/screen.ts';
import { videoModeAtom } from '@/jotai/screen.ts';
import { H264 } from './h264.tsx';
import { Mjpeg } from './mjpeg.tsx';
export const Screen = () => {
const streamUrl = useAtomValue(streamUrlAtom);
const resolution = useAtomValue(resolutionAtom);
const mouseStyle = useAtomValue(mouseStyleAtom);
const videoMode = useAtomValue(videoModeAtom);
useEffect(() => {
// stop frame detect for a while
stopFrameDetect();
}, [resolution]);
return (
<div className={clsx('flex h-screen w-screen items-start justify-center xl:items-center')}>
<Image
id="screen"
className={clsx('block select-none bg-neutral-950', mouseStyle)}
style={
resolution?.width
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }
: { maxHeight: '100vh', objectFit: 'scale-down' }
}
src={streamUrl}
fallback={MonitorXIcon}
preview={false}
/>
</div>
);
return <>{videoMode === 'mjpeg' ? <Mjpeg /> : <H264 />}</>;
};

View File

@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { Image } from 'antd';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import MonitorXIcon from '@/assets/images/monitor-x.svg';
import { stopFrameDetect } from '@/api/stream.ts';
import { getBaseUrl } from '@/lib/service.ts';
import { mouseStyleAtom } from '@/jotai/mouse.ts';
import { resolutionAtom } from '@/jotai/screen.ts';
export const Mjpeg = () => {
const resolution = useAtomValue(resolutionAtom);
const mouseStyle = useAtomValue(mouseStyleAtom);
useEffect(() => {
// stop frame detect for a while
stopFrameDetect();
}, [resolution]);
return (
<div className={clsx('flex h-screen w-screen items-start justify-center xl:items-center')}>
<Image
id="screen"
className={clsx('block select-none bg-neutral-950', mouseStyle)}
style={
resolution?.width
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }
: { maxHeight: '100vh', objectFit: 'scale-down' }
}
src={`${getBaseUrl('http')}/api/stream/mjpeg`}
fallback={MonitorXIcon}
preview={false}
/>
</div>
);
};