mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-09 17:44:43 -06:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb7194eb6c | ||
|
|
44c420e28b | ||
|
|
4b54fc4768 |
2
Makefile
2
Makefile
@@ -18,7 +18,7 @@ DOCKER_RUN_BASE := docker run -e UID=$(UID) -e GID=$(GID) -v $(PWD):/home/build/
|
||||
DOCKER_BUILD_ARGS := --build-arg DOCKER_UID=$(UID) --build-arg DOCKER_GID=$(GID)
|
||||
|
||||
# Build commands
|
||||
GO_BUILD_CMD := cd /home/build/NanoKVM/server && go mod tidy && CGO_ENABLED=1 GOOS=linux GOARCH=riscv64 CC=riscv64-unknown-linux-musl-gcc CGO_CFLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" go build
|
||||
GO_BUILD_CMD := cd /home/build/NanoKVM/server && ./build.sh
|
||||
SUPPORT_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_system && ./build kvm_system add_to_kvmapp
|
||||
VISION_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_vision && ./build kvm_vision add_to_kvmapp
|
||||
RELEASE_BUILD_CMD := /home/build/NanoKVM/scripts/build-in-container.sh
|
||||
|
||||
@@ -97,7 +97,7 @@ The repository ships a [Dev Container](https://containers.dev) setup (`.devconta
|
||||
|
||||
- Requirements: Docker plus an editor or CLI with dev container support (e.g. the VS Code "Dev Containers" extension, or `devcontainer up`).
|
||||
- On creation the container prepares itself automatically: `support/sg2002/build update_lib` (resyncs MaixCDK components with the checkout) and `pnpm install` for the frontend.
|
||||
- Backend: run `server/build.sh` for a release-equivalent binary (BoringCrypto + `dl_lib` RPATH). `make app` builds a plain development binary without those, so prefer `server/build.sh` whenever the result should match a release.
|
||||
- Backend: inside the container, run `./build.sh` from `server/`. On the host, `make app` runs the same script in Docker. Both use the prebuilt libraries in `server/dl_lib/`, enable the BoringCrypto build option, and set the `dl_lib` RPATH.
|
||||
- Support layer: `cd support/sg2002 && ./build kvm_system` (the MaixCDK virtualenv is part of the container environment).
|
||||
- Frontend: `cd web && pnpm dev` (VS Code forwards port 3001 automatically; other clients may need to map it themselves); see [web/README.md](web/README.md) for pointing it at a device.
|
||||
- The base image `ghcr.io/sipeed/nanokvm-builder:latest` is x86-64 only; on Apple Silicon it runs under emulation (works, but C builds are slow). You can also build the image locally first (`make builder-image`) and point the `BASE_IMAGE` build arg at it (e.g. via `build.args` in `devcontainer.json`). On Linux hosts the first container creation also takes a while: the container user is remapped to your uid, which re-chowns its home directory once.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#!/bin/sh
|
||||
# nanokvm Rev3.1
|
||||
|
||||
exec 9>/run/nanokvm-service.lock || exit 1
|
||||
flock -n 9 || { echo "NanoKVM service operation already in progress" >&2; exit 1; }
|
||||
|
||||
wait_for_exit() {
|
||||
process="$1"
|
||||
timeout="$2"
|
||||
@@ -34,43 +37,42 @@ stop_process() {
|
||||
fi
|
||||
|
||||
killall -KILL "$process" 2>/dev/null || true
|
||||
wait_for_exit "$process" 2 || true
|
||||
wait_for_exit "$process" 2 || {
|
||||
echo "Failed to stop $process" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
stop_services() {
|
||||
# The server owns VI/VPSS. Let it release MMF before stopping kvm_system.
|
||||
stop_process NanoKVM-Server
|
||||
stop_process kvm_system
|
||||
rm -rf /tmp/kvm_system /tmp/server
|
||||
stop_process NanoKVM-Server || return 1
|
||||
stop_process kvm_system || return 1
|
||||
}
|
||||
|
||||
start_services() {
|
||||
cp -r /kvmapp/kvm_system /tmp/
|
||||
if pidof NanoKVM-Server >/dev/null 2>&1 && pidof kvm_system >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if pidof NanoKVM-Server >/dev/null 2>&1 || pidof kvm_system >/dev/null 2>&1; then
|
||||
echo "NanoKVM is partially running; use restart" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
rm -rf /tmp/kvm_system /tmp/server || return 1
|
||||
cp -r /kvmapp/kvm_system /tmp/ || return 1
|
||||
if [ -f /kvmapp/kvm_new_app ]; then
|
||||
touch /tmp/.nanokvm_migrating
|
||||
/tmp/kvm_system/kvm_system &
|
||||
|
||||
# The legacy migration invokes this init script recursively and starts
|
||||
# the server itself. Wait for that single migration path to finish.
|
||||
migration_wait=0
|
||||
while [ "$migration_wait" -lt 30 ]; do
|
||||
if [ ! -f /kvmapp/kvm_new_app ] && pidof NanoKVM-Server >/dev/null 2>&1; then
|
||||
rm -f /tmp/.nanokvm_migrating
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
migration_wait=$((migration_wait + 1))
|
||||
done
|
||||
rm -f /tmp/.nanokvm_migrating
|
||||
else
|
||||
/tmp/kvm_system/kvm_system &
|
||||
/tmp/kvm_system/kvm_system --migrate 9>&-
|
||||
case "$?" in
|
||||
0) ;;
|
||||
2) sync; reboot; return "$?" ;;
|
||||
*) echo "NanoKVM migration failed" >&2; return 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if ! pidof NanoKVM-Server >/dev/null 2>&1; then
|
||||
rm -rf /tmp/server
|
||||
cp -r /kvmapp/server /tmp/
|
||||
/tmp/server/NanoKVM-Server &
|
||||
fi
|
||||
cp -r /kvmapp/server /tmp/ || return 1
|
||||
# Daemons must not inherit the service-operation lock.
|
||||
/tmp/kvm_system/kvm_system 9>&- &
|
||||
/tmp/server/NanoKVM-Server 9>&- &
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
@@ -118,20 +120,17 @@ case "$1" in
|
||||
fi
|
||||
|
||||
# Start services
|
||||
start_services
|
||||
start_services || exit 1
|
||||
;;
|
||||
|
||||
stop)
|
||||
stop_services
|
||||
stop_services || exit 1
|
||||
echo "OK"
|
||||
;;
|
||||
|
||||
restart)
|
||||
if [ -f /tmp/.nanokvm_migrating ]; then
|
||||
exit 0
|
||||
fi
|
||||
stop_services
|
||||
start_services
|
||||
stop_services || exit 1
|
||||
start_services || exit 1
|
||||
|
||||
sync
|
||||
echo "OK"
|
||||
|
||||
@@ -81,22 +81,27 @@ manual download and does not change the on-device `latest.json` contract.
|
||||
| `server/web/` | `web/dist/` |
|
||||
| `kvm_system/kvm_system` | `support/sg2002` `build kvm_system` |
|
||||
| `system/tool/` | prebuilt binaries in `tools/nanokvm_update_edid/` |
|
||||
| `kvm/` | default runtime state (resolution, fps, quality) |
|
||||
| `kvm/` | default runtime state (input dimensions, fps, quality) |
|
||||
| everything else | tracked `kvmapp/` |
|
||||
|
||||
Note that `kvm/` ships default runtime state, and `install.go` replaces
|
||||
`/kvmapp` wholesale, so an update resets those values on the device. That is
|
||||
long-standing behaviour, not something these scripts introduce.
|
||||
|
||||
## The two copies of libkvm.so
|
||||
## Video library linking
|
||||
|
||||
`NanoKVM-Server` is cgo-linked against the **tracked** `server/dl_lib/libkvm.so`
|
||||
(`server/common/kvm_vision.go`: `-L../dl_lib -lkvm`), but the package ships the
|
||||
library the `kvm_vision` build just produced. Those are two different files, and
|
||||
the tracked one can lag well behind — published releases have shipped this
|
||||
mismatch for a long time.
|
||||
`make app` and `server/build.sh` compile the server using the prebuilt libraries
|
||||
in `server/dl_lib/`. They do not rebuild the video libraries. The cgo directive
|
||||
in `server/common/kvm_vision.go` links against `libkvm.so`, which depends on
|
||||
`libkvm_mmf.so`.
|
||||
|
||||
Because a symbol the binary imports could in principle be absent from the
|
||||
shipped library, and that would only surface as a crash on a real device,
|
||||
`build-in-container.sh` checks the shipped `libkvm.so` still exports every
|
||||
symbol `NanoKVM-Server` actually imports from it, and fails the build otherwise.
|
||||
`make vision` rebuilds the video libraries and stages them in
|
||||
`kvmapp/server/dl_lib/`; `make release-build` does this as part of the full build.
|
||||
After changing library interfaces, update both prebuilt files in
|
||||
`server/dl_lib/` from the same build. The tracked directory also supplies the
|
||||
SDK runtime libraries used by the video build and backfills libraries that
|
||||
are absent from the staged directory.
|
||||
|
||||
`build-in-container.sh` checks that the staged `libkvm.so` exports every KVM
|
||||
symbol imported by the server. See the [server instructions](../server/README.md#compile--deploy)
|
||||
for manual compilation and deployment.
|
||||
|
||||
@@ -55,13 +55,8 @@ fi
|
||||
# shellcheck disable=SC1091
|
||||
. "$MAIXCDK_PATH/bin/activate"
|
||||
|
||||
echo "::group::support: sync MaixCDK components"
|
||||
# Each support build synchronizes the repository components before compiling.
|
||||
cd "$NANOKVM_PATH/support/sg2002"
|
||||
# The image bakes support/sg2002/additional/ into MaixCDK's components at image
|
||||
# build time, and ./build only re-copies them when components/kvm is missing.
|
||||
# Refresh unconditionally so a cached image never yields stale libraries.
|
||||
./build update_lib
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::support: build kvm_system"
|
||||
./build kvm_system
|
||||
@@ -100,11 +95,8 @@ if [ "$missing" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# NanoKVM-Server is cgo-linked against the tracked server/dl_lib/libkvm.so
|
||||
# (server/common/kvm_vision.go: -L../dl_lib -lkvm), but the package ships the
|
||||
# freshly built one from kvm_vision. Those are different files, so confirm the
|
||||
# shipped library still exports every symbol the binary actually imports from
|
||||
# it. Without this a dropped symbol only shows up as a crash on a real device.
|
||||
# Confirm the shipped video libraries provide every KVM symbol imported by
|
||||
# the server, including the frame ownership API.
|
||||
echo "[INFO] verifying shipped libkvm.so satisfies NanoKVM-Server"
|
||||
if ! command -v readelf >/dev/null 2>&1; then
|
||||
echo "[WARN] readelf unavailable, skipping ABI check"
|
||||
@@ -120,13 +112,10 @@ else
|
||||
| sed 's/@.*//' | LC_ALL=C sort -u
|
||||
}
|
||||
|
||||
linked_lib="$NANOKVM_PATH/server/dl_lib/libkvm.so"
|
||||
shipped_lib="$NANOKVM_PATH/kvmapp/server/dl_lib/libkvm.so"
|
||||
server_bin="$NANOKVM_PATH/server/NanoKVM-Server"
|
||||
|
||||
# Symbols the binary imports that the link-time library provides, i.e. the
|
||||
# ones libkvm.so is actually responsible for at runtime.
|
||||
needed=$(comm -12 <(imported "$server_bin") <(exported "$linked_lib"))
|
||||
needed=$(imported "$server_bin" | awk '/^(kvmv_|free_kvmv_data$|free_all_kvmv_data$|set_h264_gop$|set_frame_detact$|set_venc_auto_recyc$)/')
|
||||
|
||||
if [ -z "$needed" ]; then
|
||||
echo "[WARN] no libkvm symbols resolved; skipping ABI check"
|
||||
|
||||
@@ -35,64 +35,49 @@ func GetKvmVision() *KvmVision {
|
||||
return kvmVision
|
||||
}
|
||||
|
||||
func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int) {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if k.closed {
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
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))
|
||||
return
|
||||
func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) ([]byte, int) {
|
||||
frame := k.AcquireMjpeg(width, height, quality, uint16(GetScreen().FPS))
|
||||
defer frame.Release()
|
||||
return frame.Data, frame.Result
|
||||
}
|
||||
|
||||
func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data []byte, result int) {
|
||||
func (k *KvmVision) AcquireMjpeg(width, height, quality, fps uint16) *VideoFrame {
|
||||
return k.acquireFrame(width, height, quality, fps, 0)
|
||||
}
|
||||
|
||||
func (k *KvmVision) AcquireH264(width, height, bitRate, fps uint16) *VideoFrame {
|
||||
return k.acquireFrame(width, height, bitRate, fps, 1)
|
||||
}
|
||||
|
||||
func (k *KvmVision) acquireFrame(width, height, quality, fps uint16, codec uint8) *VideoFrame {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if k.closed {
|
||||
return nil, -1
|
||||
return NewVideoFrame(nil, -1, 0, nil)
|
||||
}
|
||||
|
||||
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
|
||||
var frame C.kvmv_frame_t
|
||||
result := int(C.kvmv_read_img(C.uint16_t(width), C.uint16_t(height),
|
||||
C.uint8_t(codec), C.uint16_t(quality), C.uint16_t(fps), &frame))
|
||||
if frame.data == nil {
|
||||
return NewVideoFrame(nil, result, int64(frame.timestamp), nil)
|
||||
}
|
||||
defer C.free_kvmv_data(&kvmData)
|
||||
data := C.GoBytes(unsafe.Pointer(frame.data), C.int(frame.size))
|
||||
return NewVideoFrame(data, result, int64(frame.timestamp), func() {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
C.kvmv_release_frame(&frame)
|
||||
})
|
||||
}
|
||||
|
||||
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
|
||||
return
|
||||
func (k *KvmVision) StopH264() { k.stopStream(1) }
|
||||
func (k *KvmVision) StopMjpeg() { k.stopStream(0) }
|
||||
|
||||
func (k *KvmVision) stopStream(codec uint8) {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if !k.closed {
|
||||
C.kvmv_stop_stream(C.uint8_t(codec))
|
||||
}
|
||||
}
|
||||
|
||||
func (k *KvmVision) SetHDMI(enable bool) int {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package common
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Screen struct {
|
||||
Width uint16
|
||||
Height uint16
|
||||
FPS int
|
||||
Quality uint16
|
||||
BitRate uint16
|
||||
@@ -16,15 +19,6 @@ var (
|
||||
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,
|
||||
@@ -42,8 +36,6 @@ var BitRateMap = map[uint16]bool{
|
||||
func GetScreen() *Screen {
|
||||
screenOnce.Do(func() {
|
||||
screen = &Screen{
|
||||
Width: 0,
|
||||
Height: 0,
|
||||
Quality: 80,
|
||||
FPS: 30,
|
||||
BitRate: 3000,
|
||||
@@ -56,13 +48,6 @@ func GetScreen() *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)
|
||||
@@ -79,11 +64,6 @@ func SetScreen(key string, value int) {
|
||||
}
|
||||
|
||||
func CheckScreen() {
|
||||
if _, ok := ResolutionMap[screen.Height]; !ok {
|
||||
screen.Width = 1920
|
||||
screen.Height = 1080
|
||||
}
|
||||
|
||||
if _, ok := QualityMap[screen.Quality]; !ok {
|
||||
screen.Quality = 80
|
||||
}
|
||||
@@ -93,6 +73,21 @@ func CheckScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
func InputSize() (uint16, uint16) {
|
||||
read := func(path string) uint16 {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 16)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return uint16(value)
|
||||
}
|
||||
return read("/kvmapp/kvm/width"), read("/kvmapp/kvm/height")
|
||||
}
|
||||
|
||||
func validateFPS(fps int) int {
|
||||
if fps > 60 {
|
||||
return 60
|
||||
|
||||
26
server/common/video_frame.go
Normal file
26
server/common/video_frame.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package common
|
||||
|
||||
import "sync"
|
||||
|
||||
type VideoFrame struct {
|
||||
Data []byte
|
||||
Result int
|
||||
Timestamp int64
|
||||
release func()
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func NewVideoFrame(data []byte, result int, timestamp int64, release func()) *VideoFrame {
|
||||
return &VideoFrame{Data: data, Result: result, Timestamp: timestamp, release: release}
|
||||
}
|
||||
|
||||
func (f *VideoFrame) Release() {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
f.once.Do(func() {
|
||||
if f.release != nil {
|
||||
f.release()
|
||||
}
|
||||
})
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,71 +1,43 @@
|
||||
|
||||
|
||||
#ifndef KVM_VISION_H_
|
||||
#define KVM_VISION_H_
|
||||
|
||||
#include <stdint.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>
|
||||
#include <time.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
|
||||
#define IMG_BUFFER_FULL -3
|
||||
#define IMG_VENC_ERROR -2
|
||||
#define IMG_NOT_EXIST -1
|
||||
#define IMG_MJPEG_TYPE 0
|
||||
#define IMG_H264_TYPE_IF 3
|
||||
#define IMG_H264_TYPE_PF 4
|
||||
|
||||
#define NORMAL_RES 0
|
||||
#define NEW_RES 1
|
||||
#define UNSUPPORT_RES 2
|
||||
#define UNKNOWN_RES 3
|
||||
#define ERROR_RES 4
|
||||
typedef struct {
|
||||
uint8_t *data;
|
||||
uint32_t size;
|
||||
uint64_t timestamp;
|
||||
uint64_t token;
|
||||
uint8_t codec;
|
||||
} kvmv_frame_t;
|
||||
|
||||
void kvmv_init(uint8_t _debug_info_en);
|
||||
void set_venc_auto_recyc(uint8_t _enable);
|
||||
/**********************************************************************************
|
||||
* @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
|
||||
-7: HDMI INPUT RES ERROR
|
||||
-6: Unsupported resolution, please modify it in the host settings.
|
||||
-5: Retrieving image, please wait
|
||||
-4: Modifying image resolution, please wait
|
||||
-3: img buffer full
|
||||
-2: VENC Error
|
||||
-1: No images were acquired
|
||||
0: Acquire MJPEG encoded images
|
||||
1: Acquire H264 encoded images(SPS)[Deprecated]
|
||||
2: Acquire H264 encoded images(PPS)[Deprecated]
|
||||
3: Acquire H264 encoded images(I)
|
||||
4: Acquire H264 encoded images(P)
|
||||
5: IMG not changed
|
||||
**********************************************************************************/
|
||||
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 free_kvmv_data(uint8_t ** _pp_kvm_data);
|
||||
void free_all_kvmv_data();
|
||||
void set_h264_gop(uint8_t _gop);
|
||||
void set_frame_detact(uint8_t _frame_detact);
|
||||
void kvmv_deinit();
|
||||
uint8_t kvmv_hdmi_control(uint8_t _en);
|
||||
void kvmv_init(uint8_t debug_info);
|
||||
void kvmv_deinit(void);
|
||||
|
||||
// type: 0 = MJPEG, 1 = H.264. A zero width/height follows the input size.
|
||||
// quality: MJPEG 50..100, H.264 500..10000 Kbps. fps: 1..60.
|
||||
// Returns -7 input error, -6 unsupported size, -5 busy, -4 reconfiguring,
|
||||
// -3 allocation failure, -2 encoder error, -1 no frame, 0 JPEG, 3 IDR,
|
||||
// 4 P-frame, or 5 unchanged JPEG. Release every successful frame once.
|
||||
int kvmv_read_img(uint16_t width, uint16_t height, uint8_t type,
|
||||
uint16_t quality, uint16_t fps, kvmv_frame_t *frame);
|
||||
// Frees the copied bytes and releases the encoder lease. Safe after shutdown.
|
||||
void kvmv_release_frame(kvmv_frame_t *frame);
|
||||
void kvmv_stop_stream(uint8_t type);
|
||||
void set_h264_gop(uint8_t gop);
|
||||
void set_frame_detact(uint8_t interval);
|
||||
uint8_t kvmv_hdmi_control(uint8_t enable);
|
||||
uint8_t kvmv_hdmi_signal_active(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -38,6 +38,10 @@ type MediaStateRsp struct {
|
||||
HidOnly bool `json:"hidOnly"` // the gadget has no mass storage at all
|
||||
Open []string `json:"open"` // media the file manager currently holds open
|
||||
Cdrom bool `json:"cdrom"` // the attached medium is served as a CD-ROM
|
||||
// LocalWritable is false while /data is mounted read-only, which is what
|
||||
// the target machine owning the partition does. Every write would fail
|
||||
// with EROFS, so the UI disables them instead of asking.
|
||||
LocalWritable bool `json:"localWritable"`
|
||||
}
|
||||
|
||||
type MediaRefReq struct {
|
||||
@@ -89,4 +93,8 @@ type MediaPathReq struct {
|
||||
Type string `json:"type" validate:"omitempty,oneof=image partition"`
|
||||
Rel string `json:"rel"`
|
||||
Path string `json:"path" validate:"required"`
|
||||
// ExistsOK turns "the directory is already there" into success. An upload
|
||||
// of a whole tree recreates folders that may already exist on the medium,
|
||||
// and stopping at the first one would leave the tree half copied.
|
||||
ExistsOK bool `json:"existsOk"`
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ type GetGpioRsp struct {
|
||||
}
|
||||
|
||||
type SetScreenReq struct {
|
||||
Type string `validate:"required"` // resolution / fps / quality
|
||||
Value int `validate:"number"` // value
|
||||
Type string `validate:"required,oneof=type fps quality gop"`
|
||||
Value int `validate:"number"` // value
|
||||
}
|
||||
|
||||
type GetScriptsRsp struct {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
"NanoKVM-Server/service/ws"
|
||||
"NanoKVM-Server/webcache"
|
||||
|
||||
"github.com/gin-gonic/contrib/static"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -30,6 +31,7 @@ func web(r *gin.Engine) {
|
||||
execDir := filepath.Dir(execPath)
|
||||
webPath := fmt.Sprintf("%s/web", execDir)
|
||||
|
||||
r.Use(webcache.Headers(webPath))
|
||||
r.Use(static.Serve("/", static.LocalFile(webPath, true)))
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ func storageRouter(r *gin.Engine) {
|
||||
attach.POST("/storage/media/eject", service.EjectMedia) // release the medium
|
||||
write.POST("/storage/media/delete", service.DeleteMedia) // delete a medium
|
||||
files.POST("/storage/media/files/upload", service.UploadMediaFile)
|
||||
files.POST("/storage/media/files/mkdir", service.MkdirMediaFile)
|
||||
files.POST("/storage/media/files/delete", service.DeleteMediaFile)
|
||||
|
||||
// The pre-2.6 image API, kept for scripts written against it.
|
||||
|
||||
@@ -8,9 +8,5 @@ import (
|
||||
)
|
||||
|
||||
func New() mcpservice.Snapshotter {
|
||||
return mcpcapture.NewWithCaptureLease(common.GetKvmVision(), func() (uint16, uint16) {
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
return screen.Width, screen.Height
|
||||
}, vm.AcquireHdmiCaptureLeaseForRead)
|
||||
return mcpcapture.NewWithCaptureLease(common.GetKvmVision(), common.InputSize, vm.AcquireHdmiCaptureLeaseForRead)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (s *Service) Screenshot(c *gin.Context) {
|
||||
func (s *Service) captureScreenshot(ctx context.Context, query ScreenshotQuery) ([]byte, ScreenshotMeta, *PicoclawError) {
|
||||
width, height, quality := resolveScreenshotRequest(query)
|
||||
|
||||
screen := common.GetScreen()
|
||||
sourceWidth, sourceHeight := common.InputSize()
|
||||
common.CheckScreen()
|
||||
releaseLease, claimFresh, leaseErr := s.acquireCaptureLease(ctx)
|
||||
if leaseErr != nil {
|
||||
@@ -94,8 +94,8 @@ func (s *Service) captureScreenshot(ctx context.Context, query ScreenshotQuery)
|
||||
continue
|
||||
}
|
||||
return data, ScreenshotMeta{
|
||||
SourceWidth: screen.Width,
|
||||
SourceHeight: screen.Height,
|
||||
SourceWidth: sourceWidth,
|
||||
SourceHeight: sourceHeight,
|
||||
CaptureWidth: width,
|
||||
CaptureHeight: height,
|
||||
Format: "jpeg",
|
||||
@@ -108,8 +108,7 @@ func (s *Service) captureScreenshot(ctx context.Context, query ScreenshotQuery)
|
||||
|
||||
func resolveScreenshotRequest(query ScreenshotQuery) (uint16, uint16, uint16) {
|
||||
screen := common.GetScreen()
|
||||
width := screen.Width
|
||||
height := screen.Height
|
||||
width, height := common.InputSize()
|
||||
quality := screen.Quality
|
||||
|
||||
if query.Format == "base64" {
|
||||
|
||||
@@ -3,6 +3,7 @@ package storage
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -46,11 +47,15 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
partitionCacheMu sync.Mutex
|
||||
partitionLastChange time.Time
|
||||
partitionBrowsedPath string
|
||||
partitionListeners []func()
|
||||
partitionWatchOnce sync.Once
|
||||
partitionCacheMu sync.Mutex
|
||||
partitionLastChange time.Time
|
||||
// partitionWatchPaths is every directory the file browser is showing: the
|
||||
// one it listed plus the folders it has expanded. A change inside a
|
||||
// subdirectory does not move its parent's signature, so all of them have
|
||||
// to be watched, not just the last one listed.
|
||||
partitionWatchPaths []string
|
||||
partitionListeners []func()
|
||||
partitionWatchOnce sync.Once
|
||||
)
|
||||
|
||||
// startPartitionWatcher starts the periodic refresh of the shared files view.
|
||||
@@ -62,6 +67,13 @@ func startPartitionWatcher() {
|
||||
|
||||
func watchPartitionChanges() {
|
||||
var lastSignature string
|
||||
var lastWatch []string
|
||||
// While a transfer is in progress the UI labels zero-length entries as
|
||||
// uploads. The quiet window ending is not a change on disk, so it is the
|
||||
// one transition the signature cannot report: without its own
|
||||
// notification, a finished copy keeps looking like an upload that never
|
||||
// completes.
|
||||
reportedWriting := false
|
||||
|
||||
for {
|
||||
time.Sleep(partitionWatchInterval)
|
||||
@@ -70,36 +82,81 @@ func watchPartitionChanges() {
|
||||
// this device is the only writer, the filesystem's own caches are
|
||||
// current and dropping them would be wasted work.
|
||||
if !IsAttached(partitionDevice) {
|
||||
lastSignature = ""
|
||||
lastWatch = nil
|
||||
reportedWriting = false
|
||||
continue
|
||||
}
|
||||
|
||||
invalidatePartitionCache()
|
||||
|
||||
watch := watchedDirectories()
|
||||
signature := partitionSignature(watch)
|
||||
|
||||
// The browser changing which folders it shows is not a write: the
|
||||
// signature is not comparable with the previous one, and treating the
|
||||
// difference as a transfer keeps an empty file labelled as an upload
|
||||
// forever. Re-baseline instead, without touching the quiet window.
|
||||
if !sameDirectories(watch, lastWatch) {
|
||||
lastWatch = watch
|
||||
lastSignature = signature
|
||||
continue
|
||||
}
|
||||
|
||||
// The signature is taken after the drop, so it compares what a listing
|
||||
// would show now. It is what tells a transfer in progress - the sizes
|
||||
// grow - from an idle medium, and it is also what keeps an idle shared
|
||||
// folder from making the web UI re-read it every second.
|
||||
signature := partitionSignature()
|
||||
if signature == lastSignature {
|
||||
if signature != lastSignature {
|
||||
lastSignature = signature
|
||||
|
||||
partitionCacheMu.Lock()
|
||||
partitionLastChange = time.Now()
|
||||
partitionCacheMu.Unlock()
|
||||
|
||||
reportedWriting = true
|
||||
notifyPartitionListeners()
|
||||
continue
|
||||
}
|
||||
lastSignature = signature
|
||||
|
||||
partitionCacheMu.Lock()
|
||||
partitionLastChange = time.Now()
|
||||
partitionCacheMu.Unlock()
|
||||
|
||||
notifyPartitionListeners()
|
||||
if reportedWriting && !partitionWriting() {
|
||||
reportedWriting = false
|
||||
notifyPartitionListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetPartitionBrowsePath tells the watcher which directory of the shared
|
||||
// folder the web UI is showing. A change inside a subdirectory does not move
|
||||
// the folder's own signature, so the directory being browsed is watched too.
|
||||
// An empty path stops watching a directory.
|
||||
func SetPartitionBrowsePath(browsePath string) {
|
||||
// watchedDirectories returns the directories to read, sorted so that a set the
|
||||
// browser sends in another order is still the same set.
|
||||
func watchedDirectories() []string {
|
||||
partitionCacheMu.Lock()
|
||||
partitionBrowsedPath = browsePath
|
||||
paths := append([]string{}, partitionWatchPaths...)
|
||||
partitionCacheMu.Unlock()
|
||||
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func sameDirectories(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SetPartitionWatchPaths tells the watcher which directories of the shared
|
||||
// folder the web UI is showing: the one being listed and every folder it has
|
||||
// expanded. A change inside a subdirectory does not move the folder's own
|
||||
// signature, so each open folder is watched on its own. An empty list stops
|
||||
// watching subdirectories.
|
||||
func SetPartitionWatchPaths(paths []string) {
|
||||
partitionCacheMu.Lock()
|
||||
partitionWatchPaths = append([]string{}, paths...)
|
||||
partitionCacheMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -152,25 +209,28 @@ func invalidatePartitionCache() {
|
||||
}
|
||||
|
||||
// partitionSignature summarizes what a listing would show: the shared folder
|
||||
// itself, which is where files are exchanged, and the directory the file
|
||||
// itself, which is where files are exchanged, and every directory the file
|
||||
// browser has open. Names, sizes and timestamps are all compared, because a
|
||||
// copy changes the size of an entry it already added.
|
||||
func partitionSignature() string {
|
||||
partitionCacheMu.Lock()
|
||||
browsed := partitionBrowsedPath
|
||||
partitionCacheMu.Unlock()
|
||||
func partitionSignature(paths []string) string {
|
||||
var signature strings.Builder
|
||||
signature.WriteString(directorySignature(mediaDirectory))
|
||||
|
||||
signature := directorySignature(mediaDirectory)
|
||||
if browsed == "" || browsed == "/" {
|
||||
return signature
|
||||
seen := map[string]bool{"/": true}
|
||||
for _, browsed := range paths {
|
||||
if browsed == "" || seen[browsed] {
|
||||
continue
|
||||
}
|
||||
seen[browsed] = true
|
||||
|
||||
dir, err := diskfs.ResolveInside(mediaDirectory, browsed)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
signature.WriteString(directorySignature(dir))
|
||||
}
|
||||
|
||||
dir, err := diskfs.ResolveInside(mediaDirectory, browsed)
|
||||
if err != nil {
|
||||
return signature
|
||||
}
|
||||
|
||||
return signature + directorySignature(dir)
|
||||
return signature.String()
|
||||
}
|
||||
|
||||
func directorySignature(dir string) string {
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
@@ -57,6 +60,59 @@ func SetDataWritable(writable bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DataWritable reports whether this side may write the data partition now.
|
||||
func DataWritable() bool {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(mediaDirectory, &stat); err != nil {
|
||||
return false
|
||||
}
|
||||
return stat.Flags&unix.ST_RDONLY == 0
|
||||
}
|
||||
|
||||
// healFailureInterval throttles the warning a heal that keeps failing would
|
||||
// otherwise write: the state is polled every few seconds, and a mount that
|
||||
// cannot change would fill the log with the same line.
|
||||
const healFailureInterval = time.Minute
|
||||
|
||||
var (
|
||||
healFailureMu sync.Mutex
|
||||
healFailureLast time.Time
|
||||
)
|
||||
|
||||
// logHealFailure reports a failed heal, at most once per interval.
|
||||
func logHealFailure(err error) {
|
||||
healFailureMu.Lock()
|
||||
defer healFailureMu.Unlock()
|
||||
|
||||
if time.Since(healFailureLast) < healFailureInterval {
|
||||
return
|
||||
}
|
||||
healFailureLast = time.Now()
|
||||
logrus.Warnf("heal data partition: %s", err)
|
||||
}
|
||||
|
||||
// healDataPartition puts /data back to read-write when nothing is attached to
|
||||
// it. Two things leave it read-only with no owner: a release whose remount
|
||||
// failed, and the kernel itself switching the mount when the exFAT driver hit
|
||||
// an error (the mount carries errors=remount-ro). In both the target machine
|
||||
// cannot write the partition any more, so this side taking it back is safe -
|
||||
// and without it every upload, download and folder would fail with EROFS
|
||||
// until a reboot.
|
||||
func healDataPartition() error {
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.State == diskStatePartition {
|
||||
return nil
|
||||
}
|
||||
|
||||
return SetDataWritable(true)
|
||||
}
|
||||
|
||||
// DataDiskAttached reports whether the emulated disk serves the whole data
|
||||
// partition to the target machine, which is the state /data is read-only in.
|
||||
func DataDiskAttached() bool {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package diskfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -128,3 +129,53 @@ func TestOpenFileRefusesSymlinks(t *testing.T) {
|
||||
t.Errorf("openFile read %q, size %d", data, entry.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMkdirDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
if err := mkdirDir(root, "photos"); err != nil {
|
||||
t.Fatalf("mkdirDir: %v", err)
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(root, "photos")); err != nil || !info.IsDir() {
|
||||
t.Fatalf("photos not created as a directory: %v", err)
|
||||
}
|
||||
|
||||
// Nested paths work the way the file manager builds them.
|
||||
if err := os.Mkdir(filepath.Join(root, "parent"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mkdirDir(root, "parent/child"); err != nil {
|
||||
t.Fatalf("mkdirDir nested: %v", err)
|
||||
}
|
||||
|
||||
// An existing name is reported, not replaced. The sentinel lets a caller
|
||||
// that recreates a whole tree treat it as success.
|
||||
if err := mkdirDir(root, "photos"); !errors.Is(err, ErrExists) {
|
||||
t.Errorf("mkdirDir on an existing directory = %v, want ErrExists", err)
|
||||
}
|
||||
if err := mkdirDir(root, "/"); err == nil {
|
||||
t.Error("mkdirDir accepted the medium root")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mkdirDir(root, "notes.txt"); err == nil || errors.Is(err, ErrExists) {
|
||||
t.Errorf("mkdirDir on a file = %v, want a plain not-a-directory error", err)
|
||||
}
|
||||
if err := mkdirDir(root, "../escape"); err == nil {
|
||||
t.Error("mkdirDir accepted a path outside the medium")
|
||||
}
|
||||
|
||||
// A symlinked directory is not a place to create anything: the entry would
|
||||
// land wherever the link points, outside the medium.
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mkdirDir(root, "link/escape"); err == nil {
|
||||
t.Error("mkdirDir followed a symlink out of the medium")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "escape")); err == nil {
|
||||
t.Error("mkdirDir created a directory outside the medium")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ type Image interface {
|
||||
List(rel string) ([]Entry, error)
|
||||
Open(rel string) (io.ReadSeekCloser, Entry, error)
|
||||
Write(rel string, r io.Reader) (int64, error)
|
||||
Mkdir(rel string) error
|
||||
Remove(rel string) error
|
||||
StatFS() (total, available uint64, err error)
|
||||
Close() error
|
||||
@@ -315,6 +316,13 @@ func (h *handle) Remove(rel string) error {
|
||||
return removeFile(h.mount.mountpoint, rel)
|
||||
}
|
||||
|
||||
func (h *handle) Mkdir(rel string) error {
|
||||
if !h.mount.writable {
|
||||
return errors.New("medium is mounted read-only")
|
||||
}
|
||||
return mkdirDir(h.mount.mountpoint, rel)
|
||||
}
|
||||
|
||||
func (h *handle) StatFS() (total, available uint64, err error) {
|
||||
return utils.Statfs(h.mount.mountpoint)
|
||||
}
|
||||
@@ -358,6 +366,10 @@ func (h *dirHandle) Remove(rel string) error {
|
||||
return removeFile(h.root, rel)
|
||||
}
|
||||
|
||||
func (h *dirHandle) Mkdir(rel string) error {
|
||||
return mkdirDir(h.root, rel)
|
||||
}
|
||||
|
||||
func (h *dirHandle) StatFS() (total, available uint64, err error) {
|
||||
return utils.Statfs(h.root)
|
||||
}
|
||||
@@ -437,7 +449,7 @@ func writeFile(root, rel string, r io.Reader) (int64, error) {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := os.Stat(full); err == nil {
|
||||
return 0, fmt.Errorf("%s already exists", rel)
|
||||
return 0, fmt.Errorf("%s: %w", rel, ErrExists)
|
||||
}
|
||||
|
||||
tempPath := filepath.Join(filepath.Dir(full), UploadPrefix+randomSuffix())
|
||||
@@ -466,6 +478,62 @@ func writeFile(root, rel string, r io.Reader) (int64, error) {
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// ErrExists reports a name that is already taken inside the medium.
|
||||
var ErrExists = errors.New("already exists")
|
||||
|
||||
// mkdirDir creates rel as a directory inside the medium. It refuses to replace
|
||||
// anything: a name the target machine or the user already put there is a
|
||||
// mistake to report, not to overwrite.
|
||||
func mkdirDir(root, rel string) error {
|
||||
full, err := ResolveInside(root, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if full == root {
|
||||
return errors.New("refusing to create the medium root")
|
||||
}
|
||||
// ResolveInside is lexical. A directory the target machine put there can be
|
||||
// a symlink elsewhere, and a create through it would land outside the
|
||||
// medium; the parent is what has to be real, since the entry does not exist
|
||||
// yet.
|
||||
if err := ensureInside(root, filepath.Dir(full)); err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := os.Stat(full); err == nil {
|
||||
// A name taken by a file is not a folder that is already there: the
|
||||
// caller's tree upload would otherwise treat it as success and fail on
|
||||
// every file it puts inside.
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", rel)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", rel, ErrExists)
|
||||
}
|
||||
if err := os.Mkdir(full, 0o755); err != nil {
|
||||
return fmt.Errorf("create directory %s: %w", rel, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureInside refuses a directory whose real path leaves root through a
|
||||
// symlink. Both sides are resolved, so a medium that is itself reached through
|
||||
// a link still compares correctly.
|
||||
func ensureInside(root, dir string) error {
|
||||
realRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
realDir, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inside, err := filepath.Rel(realRoot, realDir)
|
||||
if err != nil || inside == ".." || strings.HasPrefix(inside, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("%w: %s leaves the medium", utils.ErrInvalidMediaPath, dir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeFile(root, rel string) error {
|
||||
full, err := ResolveInside(root, rel)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -113,14 +115,15 @@ func (s *Service) ListMediaFiles(c *gin.Context) {
|
||||
}
|
||||
|
||||
if mediaType == proto.MediaTypePartition {
|
||||
// The watcher pushes "the shared files changed" for the folder the UI
|
||||
// has open, which is not necessarily its root, so it is told which one
|
||||
// that is. Listing anything else stops that watch.
|
||||
SetPartitionBrowsePath(dir)
|
||||
// The browser tells the watcher every directory it is showing: the one
|
||||
// it listed plus the folders it has expanded. Watching only the last
|
||||
// one left the others stale until a manual refresh.
|
||||
SetPartitionWatchPaths(watchPaths(dir, c.Query("watch")))
|
||||
invalidatePartitionCache()
|
||||
} else {
|
||||
SetPartitionBrowsePath("")
|
||||
}
|
||||
// An image listing deliberately leaves that set alone: another browser may
|
||||
// still be showing the shared folder, and clearing it would stop its
|
||||
// updates until the next listing.
|
||||
|
||||
image, err := openMedium(mediaType, rel, false)
|
||||
if err != nil {
|
||||
@@ -309,6 +312,13 @@ func (s *Service) UploadMediaFile(c *gin.Context) {
|
||||
if err != nil {
|
||||
s.finishJob(done, jobStateFailed, err.Error())
|
||||
|
||||
// The UI translates this one: refusing to overwrite is a normal answer
|
||||
// to an upload, not a failure whose English text needs to be shown.
|
||||
if errors.Is(err, diskfs.ErrExists) {
|
||||
rsp.ErrRsp(c, codeAlreadyExists, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var tooLarge *http.MaxBytesError
|
||||
if limitedBySpace && errors.As(err, &tooLarge) {
|
||||
respondError(c, fmt.Errorf("%w: the medium could not hold the upload", errNoSpace))
|
||||
@@ -417,6 +427,103 @@ func (s *Service) DeleteMediaFile(c *gin.Context) {
|
||||
log.Debugf("delete %s from medium %s success", entryPath, req.Rel)
|
||||
}
|
||||
|
||||
// MkdirMediaFile creates a directory inside a medium. The web file manager
|
||||
// offers it next to the upload button: a folder the user can drop files into
|
||||
// is how a disk that started empty becomes organised. It is a write like any
|
||||
// other, so it goes through openMedium and is refused while the target machine
|
||||
// owns the partition.
|
||||
func (s *Service) MkdirMediaFile(c *gin.Context) {
|
||||
var req proto.MediaPathReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
|
||||
return
|
||||
}
|
||||
if !requireFileWrite(c, req.Type) {
|
||||
return
|
||||
}
|
||||
|
||||
entryPath, err := utils.ValidateMediumPath(req.Path)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if entryPath == "/" {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "a directory path is required")
|
||||
return
|
||||
}
|
||||
|
||||
image, err := openMedium(req.Type, req.Rel, true)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer closeMedia(image)
|
||||
|
||||
if err := image.Mkdir(entryPath); err != nil {
|
||||
if errors.Is(err, diskfs.ErrExists) {
|
||||
if !req.ExistsOK {
|
||||
rsp.ErrRsp(c, codeAlreadyExists, fmt.Sprintf("%s already exists", entryPath))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
respondError(c, fmt.Errorf("create %s: %w", entryPath, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// The shared partition has a metadata cache the listing reads; the
|
||||
// directory has to show up in the next list.
|
||||
if req.Type == proto.MediaTypePartition {
|
||||
invalidatePartitionCache()
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("create directory %s in medium %s success", entryPath, req.Rel)
|
||||
}
|
||||
|
||||
// maxWatchPaths caps the directories a single browser can ask the watcher to
|
||||
// read every second. The file manager expands a handful at most.
|
||||
const maxWatchPaths = 64
|
||||
|
||||
// watchPaths reads the directories the file browser is showing: the one being
|
||||
// listed plus every folder it has expanded. Anything that does not name a
|
||||
// directory inside the medium is dropped.
|
||||
func watchPaths(listed string, raw string) []string {
|
||||
paths := []string{listed}
|
||||
seen := map[string]bool{listed: true}
|
||||
|
||||
var candidates []string
|
||||
if raw = strings.TrimSpace(raw); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &candidates); err != nil {
|
||||
// An older client sent a comma separated list; a folder name may
|
||||
// contain a comma, which is exactly why JSON is used now.
|
||||
candidates = strings.Split(raw, ",")
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
cleaned, err := utils.ValidateMediumPath(candidate)
|
||||
if err != nil || seen[cleaned] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[cleaned] = true
|
||||
paths = append(paths, cleaned)
|
||||
if len(paths) >= maxWatchPaths {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// nextFilePart walks the multipart body to the uploaded file, skipping any
|
||||
// field that came before it.
|
||||
func nextFilePart(reader *multipart.Reader) (*multipart.Part, error) {
|
||||
|
||||
72
server/service/storage/files_test.go
Normal file
72
server/service/storage/files_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWatchPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
listed string
|
||||
raw string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "listed directory is always watched",
|
||||
listed: "/",
|
||||
raw: "",
|
||||
want: []string{"/"},
|
||||
},
|
||||
{
|
||||
// The handler passes the already cleaned path it is listing; the
|
||||
// browser's expanded folders are cleaned the same way.
|
||||
name: "expanded folders are added",
|
||||
listed: "share",
|
||||
raw: `["/share/a","/share/b"]`,
|
||||
want: []string{"share", "share/a", "share/b"},
|
||||
},
|
||||
{
|
||||
// A comma is legal in a folder name, which is why the list is JSON.
|
||||
name: "a comma stays inside the name",
|
||||
listed: "/",
|
||||
raw: `["/a,b"]`,
|
||||
want: []string{"/", "a,b"},
|
||||
},
|
||||
{
|
||||
name: "duplicates and blanks are dropped",
|
||||
listed: "share",
|
||||
raw: `["/share","","/share/a","/share/a"]`,
|
||||
want: []string{"share", "share/a"},
|
||||
},
|
||||
{
|
||||
name: "a plain comma list still parses",
|
||||
listed: "/",
|
||||
raw: "/a,/b",
|
||||
want: []string{"/", "a", "b"},
|
||||
},
|
||||
{
|
||||
name: "paths that escape the medium are dropped",
|
||||
listed: "/",
|
||||
raw: `["../etc","/share/../../etc"]`,
|
||||
want: []string{"/"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := watchPaths(tt.listed, tt.raw); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("%s: watchPaths(%q, %q) = %v, want %v", tt.name, tt.listed, tt.raw, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchPathsCap(t *testing.T) {
|
||||
raw := ""
|
||||
for i := 0; i < maxWatchPaths+10; i++ {
|
||||
raw += "/dir" + string(rune('a'+i%26)) + string(rune('a'+i/26)) + ","
|
||||
}
|
||||
|
||||
if got := len(watchPaths("/", raw)); got > maxWatchPaths {
|
||||
t.Errorf("watchPaths kept %d entries, want at most %d", got, maxWatchPaths)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ const (
|
||||
codeNotBrowsable = -13
|
||||
codeInsufficientSpace = -14
|
||||
codeNotOptical = -15
|
||||
codeAlreadyExists = -16
|
||||
)
|
||||
|
||||
// partitionDevice is the whole data partition. It is what the emulated disk
|
||||
@@ -114,6 +115,12 @@ func (s *Service) GetMedia(c *gin.Context) {
|
||||
func (s *Service) GetMediaState(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
// The UI polls this, so a /data left read-only by a failed release is put
|
||||
// back here rather than waiting for the user to reboot.
|
||||
if err := healDataPartition(); err != nil {
|
||||
logHealFailure(err)
|
||||
}
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
@@ -132,10 +139,11 @@ func (s *Service) GetMediaState(c *gin.Context) {
|
||||
}
|
||||
|
||||
state := &proto.MediaStateRsp{
|
||||
State: string(status.State),
|
||||
HidOnly: status.HidOnly,
|
||||
Open: openMediaIDs(),
|
||||
Cdrom: cdrom,
|
||||
State: string(status.State),
|
||||
HidOnly: status.HidOnly,
|
||||
Open: openMediaIDs(),
|
||||
Cdrom: cdrom,
|
||||
LocalWritable: DataWritable(),
|
||||
}
|
||||
|
||||
switch status.State {
|
||||
@@ -659,6 +667,13 @@ func openMediumLocked(mediaType, rel string, writable bool) (diskfs.Image, error
|
||||
if writable && status.State == diskStatePartition {
|
||||
return nil, errPartitionOwned
|
||||
}
|
||||
// Nothing owns the partition, so a read-only /data here is a leftover the
|
||||
// mount did not clear. Writing through it would fail with EROFS.
|
||||
if writable {
|
||||
if err := SetDataWritable(true); err != nil {
|
||||
return nil, fmt.Errorf("make %s writable: %w", mediaDirectory, err)
|
||||
}
|
||||
}
|
||||
|
||||
if mediaType == proto.MediaTypePartition {
|
||||
// The partition needs no loop device: the emulated disk already hands
|
||||
@@ -714,6 +729,10 @@ func BeginDataPartitionWrite() (func(), error) {
|
||||
mediumOperationMu.Unlock()
|
||||
return nil, ErrDataPartitionInUse
|
||||
}
|
||||
if err := SetDataWritable(true); err != nil {
|
||||
mediumOperationMu.Unlock()
|
||||
return nil, fmt.Errorf("make %s writable: %w", mediaDirectory, err)
|
||||
}
|
||||
partitionWriteRefs.Add(1)
|
||||
mediumOperationMu.Unlock()
|
||||
|
||||
|
||||
@@ -3,23 +3,20 @@ package stream
|
||||
import (
|
||||
"NanoKVM-Server/common"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type H264Frame struct {
|
||||
Data []byte
|
||||
Result int
|
||||
Duration time.Duration
|
||||
Timestamp int64
|
||||
}
|
||||
|
||||
type H264Subscription struct {
|
||||
frames chan H264Frame
|
||||
done chan struct{}
|
||||
closed atomic.Bool
|
||||
once sync.Once
|
||||
waitingForKeyframe bool
|
||||
source *H264Source
|
||||
frames chan H264Frame
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
type H264Source struct {
|
||||
@@ -38,7 +35,8 @@ func SubscribeH264() *H264Subscription {
|
||||
|
||||
func (s *H264Source) subscribe() *H264Subscription {
|
||||
subscription := &H264Subscription{
|
||||
frames: make(chan H264Frame, 4),
|
||||
source: s,
|
||||
frames: make(chan H264Frame),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
@@ -58,6 +56,12 @@ func (s *H264Source) subscribe() *H264Subscription {
|
||||
}
|
||||
|
||||
func (s *H264Subscription) Next() (H264Frame, bool) {
|
||||
select {
|
||||
case <-s.done:
|
||||
return H264Frame{}, false
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case frame := <-s.frames:
|
||||
return frame, true
|
||||
@@ -68,9 +72,8 @@ func (s *H264Subscription) Next() (H264Frame, bool) {
|
||||
|
||||
func (s *H264Subscription) Close() {
|
||||
s.once.Do(func() {
|
||||
s.closed.Store(true)
|
||||
close(s.done)
|
||||
defaultH264Source.remove(s)
|
||||
s.source.remove(s)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,18 +86,19 @@ func (s *H264Source) remove(subscription *H264Subscription) {
|
||||
func (s *H264Source) run() {
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
fps := screen.FPS
|
||||
ticker := time.NewTicker(time.Second / time.Duration(fps))
|
||||
defer ticker.Stop()
|
||||
|
||||
vision := common.GetKvmVision()
|
||||
startTime := time.Now()
|
||||
s.capture(screen, vision.AcquireH264, vision.StopH264)
|
||||
}
|
||||
|
||||
for range ticker.C {
|
||||
func (s *H264Source) capture(screen *common.Screen, acquire func(uint16, uint16, uint16, uint16) *common.VideoFrame, stop func()) {
|
||||
for {
|
||||
subscribers := s.snapshot()
|
||||
if len(subscribers) == 0 {
|
||||
s.mutex.Lock()
|
||||
if len(s.subscribers) == 0 {
|
||||
if stop != nil {
|
||||
stop()
|
||||
}
|
||||
s.running = false
|
||||
s.mutex.Unlock()
|
||||
return
|
||||
@@ -103,33 +107,21 @@ func (s *H264Source) run() {
|
||||
continue
|
||||
}
|
||||
|
||||
if screen.FPS != fps && screen.FPS != 0 {
|
||||
fps = screen.FPS
|
||||
ticker.Reset(time.Second / time.Duration(fps))
|
||||
}
|
||||
|
||||
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
|
||||
if result < 0 {
|
||||
frame := H264Frame{Result: result}
|
||||
for _, subscription := range subscribers {
|
||||
subscription.send(frame)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
current := acquire(0, 0, screen.BitRate, uint16(screen.FPS))
|
||||
frame := H264Frame{
|
||||
Data: data,
|
||||
Result: result,
|
||||
Duration: time.Second / time.Duration(fps),
|
||||
Timestamp: time.Since(startTime).Microseconds(),
|
||||
Data: current.Data,
|
||||
Result: current.Result,
|
||||
Timestamp: current.Timestamp,
|
||||
}
|
||||
for _, subscription := range subscribers {
|
||||
subscription.send(frame)
|
||||
}
|
||||
GetFrameRateCounter().Update()
|
||||
current.Release()
|
||||
if frame.Result >= 0 && len(frame.Data) > 0 {
|
||||
GetFrameRateCounter().Update()
|
||||
} else {
|
||||
time.Sleep(time.Second / time.Duration(max(screen.FPS, 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,41 +137,15 @@ func (s *H264Source) snapshot() []*H264Subscription {
|
||||
}
|
||||
|
||||
func (s *H264Subscription) send(frame H264Frame) bool {
|
||||
if s.closed.Load() {
|
||||
select {
|
||||
case <-s.done:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
if s.waitingForKeyframe {
|
||||
if frame.Result != 3 {
|
||||
return false
|
||||
}
|
||||
s.waitingForKeyframe = false
|
||||
}
|
||||
|
||||
select {
|
||||
case s.frames <- frame:
|
||||
return true
|
||||
case <-s.done:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.frames:
|
||||
case <-s.done:
|
||||
return false
|
||||
default:
|
||||
s.waitingForKeyframe = true
|
||||
if frame.Result != 3 {
|
||||
return false
|
||||
}
|
||||
s.waitingForKeyframe = false
|
||||
select {
|
||||
case s.frames <- frame:
|
||||
return true
|
||||
case <-s.done:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"NanoKVM-Server/common"
|
||||
"NanoKVM-Server/service/stream"
|
||||
"NanoKVM-Server/service/vm"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -83,32 +85,69 @@ func (s *Streamer) getClients() []*gin.Context {
|
||||
}
|
||||
|
||||
func (s *Streamer) run() {
|
||||
defer atomic.StoreInt32(&s.running, 0)
|
||||
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
fps := screen.FPS
|
||||
|
||||
vision := common.GetKvmVision()
|
||||
s.streamFrames(screen, vision.AcquireMjpeg, vision.StopMjpeg)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(time.Second / time.Duration(fps))
|
||||
defer ticker.Stop()
|
||||
func (s *Streamer) streamFrames(screen *common.Screen, acquire func(uint16, uint16, uint16, uint16) *common.VideoFrame, stop func()) {
|
||||
frames := make(chan *common.VideoFrame)
|
||||
done := make(chan struct{})
|
||||
captureDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(captureDone)
|
||||
defer stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
frame := acquire(0, 0, screen.Quality, uint16(screen.FPS))
|
||||
select {
|
||||
case frames <- frame:
|
||||
frame.Release()
|
||||
case <-done:
|
||||
frame.Release()
|
||||
return
|
||||
}
|
||||
if frame.Result < 0 {
|
||||
select {
|
||||
case <-time.After(time.Second / time.Duration(max(screen.FPS, 1))):
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for range ticker.C {
|
||||
for {
|
||||
clients := s.getClients()
|
||||
if len(clients) == 0 {
|
||||
log.Debug("mjpeg stream stopped due to no clients")
|
||||
return
|
||||
s.mutex.Lock()
|
||||
if len(s.clients) == 0 {
|
||||
close(done)
|
||||
<-captureDone
|
||||
atomic.StoreInt32(&s.running, 0)
|
||||
s.mutex.Unlock()
|
||||
log.Debug("mjpeg stream stopped due to no clients")
|
||||
return
|
||||
}
|
||||
s.mutex.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
data, result := vision.ReadMjpeg(screen.Width, screen.Height, screen.Quality)
|
||||
frame := <-frames
|
||||
data, result := frame.Data, frame.Result
|
||||
stream.UpdateCaptureStatus(stream.CaptureModeMJPEG, result)
|
||||
if result < 0 || result == 5 || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.frameCacheEnabled() {
|
||||
s.setLatestFrame(data, screen.Width, screen.Height)
|
||||
s.setLatestFrame(data)
|
||||
}
|
||||
|
||||
for _, client := range clients {
|
||||
@@ -118,16 +157,15 @@ func (s *Streamer) run() {
|
||||
}
|
||||
}
|
||||
|
||||
if screen.FPS != fps && screen.FPS != 0 {
|
||||
fps = screen.FPS
|
||||
ticker.Reset(time.Second / time.Duration(fps))
|
||||
}
|
||||
|
||||
stream.GetFrameRateCounter().Update()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Streamer) setLatestFrame(data []byte, width uint16, height uint16) {
|
||||
func (s *Streamer) setLatestFrame(data []byte) {
|
||||
size, err := jpeg.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
frameCopy := append([]byte(nil), data...)
|
||||
|
||||
s.frameMutex.Lock()
|
||||
@@ -135,8 +173,8 @@ func (s *Streamer) setLatestFrame(data []byte, width uint16, height uint16) {
|
||||
|
||||
s.latestFrame = LatestFrame{
|
||||
Data: frameCopy,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Width: uint16(size.Width),
|
||||
Height: uint16(size.Height),
|
||||
CapturedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,14 @@ import (
|
||||
"NanoKVM-Server/service/vm"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/rtp/codecs"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func NewWebRTCManager() *WebRTCManager {
|
||||
m := &WebRTCManager{
|
||||
clients: make(map[*websocket.Conn]*Client),
|
||||
videoPacketizer: rtp.NewPacketizer(
|
||||
1200,
|
||||
100,
|
||||
0x1234ABCD,
|
||||
&codecs.H264Payloader{},
|
||||
rtp.NewRandomSequencer(),
|
||||
90000,
|
||||
),
|
||||
videoSending: false,
|
||||
clients: make(map[*websocket.Conn]*Client),
|
||||
videoPacketizer: newH264Packetizer(),
|
||||
videoSending: false,
|
||||
}
|
||||
m.updateClientSnapshotLocked()
|
||||
|
||||
@@ -85,7 +75,7 @@ func (m *WebRTCManager) StartVideoStream() {
|
||||
m.videoSending = true
|
||||
m.mutex.Unlock()
|
||||
|
||||
go m.sendVideoStream()
|
||||
go m.writeVideoStream()
|
||||
log.Debugf("start sending h264 stream")
|
||||
}
|
||||
|
||||
@@ -101,28 +91,21 @@ func (m *WebRTCManager) stopVideoStreamIfIdle() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *WebRTCManager) sendVideoStream() {
|
||||
func (m *WebRTCManager) writeVideoStream() {
|
||||
subscription := stream.SubscribeH264()
|
||||
defer subscription.Close()
|
||||
samples, writerDone := m.startVideoWriter()
|
||||
|
||||
for {
|
||||
frame, ok := subscription.Next()
|
||||
if !ok {
|
||||
close(samples)
|
||||
<-writerDone
|
||||
return
|
||||
}
|
||||
clients := m.getClients()
|
||||
if len(clients) == 0 {
|
||||
close(samples)
|
||||
<-writerDone
|
||||
if m.stopVideoStreamIfIdle() {
|
||||
log.Debugf("stop sending h264 stream")
|
||||
return
|
||||
}
|
||||
samples, writerDone = m.startVideoWriter()
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -131,33 +114,13 @@ func (m *WebRTCManager) sendVideoStream() {
|
||||
continue
|
||||
}
|
||||
|
||||
sample := media.Sample{
|
||||
Data: frame.Data,
|
||||
Duration: frame.Duration,
|
||||
}
|
||||
|
||||
samples <- sample
|
||||
}
|
||||
}
|
||||
|
||||
func (m *WebRTCManager) startVideoWriter() (chan media.Sample, <-chan struct{}) {
|
||||
samples := make(chan media.Sample, 1)
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
for sample := range samples {
|
||||
packets := m.videoPacketizer.Packetize(sample.Data, uint32(sample.Duration.Seconds()*90000))
|
||||
for _, client := range m.getClients() {
|
||||
err := client.track.writeVideoPackets(packets)
|
||||
if err != nil {
|
||||
log.Errorf("failed to write h264 video to client: %s", err)
|
||||
m.RemoveClient(client.WsConn())
|
||||
client.Close()
|
||||
}
|
||||
packets := m.videoPacketizer.Packetize(frame.Data, frame.Timestamp)
|
||||
for _, client := range clients {
|
||||
if err := client.track.writeVideoPackets(packets); err != nil {
|
||||
log.Errorf("failed to write h264 video to client: %s", err)
|
||||
m.RemoveClient(client.WsConn())
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return samples, done
|
||||
}
|
||||
}
|
||||
|
||||
33
server/service/stream/webrtc/packetizer.go
Normal file
33
server/service/stream/webrtc/packetizer.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/rtp/codecs"
|
||||
)
|
||||
|
||||
type h264Packetizer struct {
|
||||
packetizer rtp.Packetizer
|
||||
lastTicks uint64
|
||||
initialized bool
|
||||
}
|
||||
|
||||
func newH264Packetizer() *h264Packetizer {
|
||||
return &h264Packetizer{
|
||||
packetizer: rtp.NewPacketizer(1200, 100, 0x1234ABCD,
|
||||
&codecs.H264Payloader{}, rtp.NewRandomSequencer(), 90000),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *h264Packetizer) Packetize(data []byte, timestamp int64) []*rtp.Packet {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ticks := uint64(timestamp) * 90 / 1000
|
||||
if p.initialized {
|
||||
p.packetizer.SkipSamples(uint32(ticks - p.lastTicks))
|
||||
}
|
||||
p.lastTicks = ticks
|
||||
p.initialized = true
|
||||
return p.packetizer.Packetize(data, 0)
|
||||
}
|
||||
@@ -5,14 +5,13 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type WebRTCManager struct {
|
||||
clients map[*websocket.Conn]*Client
|
||||
clientSnapshot atomic.Pointer[[]*Client]
|
||||
videoPacketizer rtp.Packetizer
|
||||
videoPacketizer *h264Packetizer
|
||||
videoSending bool
|
||||
mutex sync.Mutex
|
||||
viewerVersion uint64
|
||||
|
||||
@@ -13,10 +13,9 @@ import (
|
||||
)
|
||||
|
||||
var screenFileMap = map[string]string{
|
||||
"type": "/kvmapp/kvm/type",
|
||||
"fps": "/kvmapp/kvm/fps",
|
||||
"quality": "/kvmapp/kvm/qlty",
|
||||
"resolution": "/kvmapp/kvm/res",
|
||||
"type": "/kvmapp/kvm/type",
|
||||
"fps": "/kvmapp/kvm/fps",
|
||||
"quality": "/kvmapp/kvm/qlty",
|
||||
}
|
||||
|
||||
func (s *Service) SetScreen(c *gin.Context) {
|
||||
|
||||
45
server/webcache/webcache.go
Normal file
45
server/webcache/webcache.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Package webcache labels the static files the server hands out.
|
||||
//
|
||||
// The frontend is one entry document plus content-hashed assets. The document
|
||||
// is what an update replaces, and a browser that keeps it keeps the old app:
|
||||
// the storage window of a release that updates but "looks unchanged" is that
|
||||
// cache. Assets carry a hash in their name, so a name that is already cached
|
||||
// can never change meaning.
|
||||
package webcache
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Headers returns the middleware that sets Cache-Control for the web root.
|
||||
// Paths it does not recognise are left alone.
|
||||
func Headers(root string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Clean first: "/assets/../x" is not an asset, and it must not be
|
||||
// resolved outside root either.
|
||||
requestPath := path.Clean("/" + c.Request.URL.Path)
|
||||
|
||||
switch {
|
||||
case requestPath == "/" || requestPath == "/index.html":
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
case strings.HasPrefix(requestPath, "/assets/") && assetExists(root, requestPath):
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// assetExists reports whether the request names a file the build shipped. A
|
||||
// missing hashed asset is answered with a 404, and marking that immutable would
|
||||
// keep the URL broken in the browser for a year - which is exactly what a
|
||||
// rollback to a build whose assets still exist would run into.
|
||||
func assetExists(root, requestPath string) bool {
|
||||
relative := strings.TrimPrefix(requestPath, "/")
|
||||
info, err := os.Stat(filepath.Join(root, filepath.FromSlash(relative)))
|
||||
return err == nil && info.Mode().IsRegular()
|
||||
}
|
||||
64
server/webcache/webcache_test.go
Normal file
64
server/webcache/webcache_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package webcache
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, content := range map[string]string{
|
||||
"index.html": "x",
|
||||
"assets/index-abc123.js": "x",
|
||||
"assets/style-abc123.css": "x",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(name)), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/", "no-cache"},
|
||||
{"/index.html", "no-cache"},
|
||||
{"/assets/index-abc123.js", "public, max-age=31536000, immutable"},
|
||||
{"/assets/style-abc123.css", "public, max-age=31536000, immutable"},
|
||||
// A missing asset is answered by a 404, and caching that for a year is
|
||||
// how a rollback to a build whose assets still exist stays broken.
|
||||
{"/assets/missing.js", ""},
|
||||
// Classified after cleaning, so a traversal never reaches the assets
|
||||
// branch or the filesystem.
|
||||
{"/assets/../index.html", "no-cache"},
|
||||
// Everything else keeps the server's default: the icon and the API
|
||||
// must not be pinned to a policy they were never reviewed for.
|
||||
{"/sipeed.ico", ""},
|
||||
{"/mockServiceWorker.js", ""},
|
||||
{"/api/auth/login", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
engine := gin.New()
|
||||
engine.Use(Headers(root))
|
||||
engine.NoRoute(func(c *gin.Context) { c.String(http.StatusNotFound, "") })
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if got := recorder.Header().Get("Cache-Control"); got != tt.want {
|
||||
t.Errorf("%s: Cache-Control = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
# NanoKVM Support Instructions
|
||||
|
||||
## Environment Preparation
|
||||
1. NanoKVM-Lite/Full/PCIe is based on the SG2002 as the main control chip. The projects in the support section are compiled under the [MaixCDK](https://github.com/sipeed/MaixCDK) framework. Before compiling, please ensure that the `MaixCDK` environment is correctly configured. For configuration instructions, click [here](https://github.com/sipeed/MaixCDK/blob/main/docs/doc_zh/README.md).
|
||||
|
||||
NanoKVM-Lite/Full/PCIe uses the SG2002. The system daemon and `libkvm.so` wrapper
|
||||
build with [MaixCDK](https://github.com/sipeed/MaixCDK); `libkvm_mmf.so` builds
|
||||
separately with CMake using the Sophgo sources in that checkout. Configure and
|
||||
activate the [MaixCDK environment](https://github.com/sipeed/MaixCDK/blob/main/docs/doc_zh/README.md)
|
||||
and add the RISC-V musl toolchain to `PATH`. Run the commands below from
|
||||
`support/sg2002/`. Alternatively, `make vision` at the repository root builds
|
||||
the video libraries in Docker. `make app` builds the server using the prebuilt
|
||||
libraries in `server/dl_lib/`.
|
||||
|
||||
## kvm_system Compilation Instructions
|
||||
|
||||
@@ -11,16 +19,17 @@
|
||||
2. Modify the paths of `MAIXCDK_PATH` and `NanoKVM_PATH` in `./build`.
|
||||
3. Execute `./build kvm_system` to compile `kvm_system`.
|
||||
4. Use `scp ./kvm_system/dist/kvm_system_release/kvm_system root@192.168.x.x:/kvmapp/kvm_system` to copy it to NanoKVM for testing.
|
||||
5. Use `./build add_to_kvmapp` to place the executable file into the `/kvmapp` installation package.
|
||||
5. Use `./build kvm_system add_to_kvmapp` to stage the executable in the repository's `kvmapp/` directory.
|
||||
6. Use `./build kvm_system clean` to clean the compilation of `kvm_system`.
|
||||
|
||||
## kvm_vision Compilation Instructions
|
||||
|
||||
> `kvm_vision` refers to the image acquisition and encoding subsystem of NanoKVM, compiled with MaixCDK to produce dynamic libraries for Go calls. Use `kvm_vision_test` to compile and test the dynamic library.
|
||||
> `kvm_vision` builds `libkvm_mmf.so`, then the MaixCDK wrapper `libkvm.so` and the `kvm_vision_test` example.
|
||||
|
||||
1. Before compiling, please ensure that the above-mentioned `MaixCDK` environment is correctly configured.
|
||||
2. Modify the paths of `MAIXCDK_PATH` and `NanoKVM_PATH` in `./build`.
|
||||
3. Execute `./build kvm_vision` to compile `kvm_vision_test`.
|
||||
4. You can test the dynamic libraries in `kvm_vision_test/dist/kvm_vision_test_release/dl_lib/`.
|
||||
5. Use `./build add_to_kvmapp` to place the dynamic libraries into the `/kvmapp` installation package.
|
||||
6. Use `./build kvm_vision clean` to clean the compilation of `kvm_vision`.
|
||||
3. Execute `./build kvm_vision` to build both libraries and the example. The library build and install directories are `kvm_vision_test/build/kvm_mmf-build/` and `kvm_vision_test/build/kvm_mmf-install/`.
|
||||
4. The resulting libraries are in `kvm_vision_test/dist/kvm_vision_test_release/dl_lib/`.
|
||||
5. Run `./build kvm_vision add_to_kvmapp` to stage them in the repository's `kvmapp/server/dl_lib/`.
|
||||
6. After changing the video library interfaces, update both prebuilt files `server/dl_lib/libkvm.so` and `server/dl_lib/libkvm_mmf.so` from the same build. Server compilation uses these files directly; see the [server build instructions](../../server/README.md#compile--deploy).
|
||||
7. Use `./build kvm_vision clean` to remove the build outputs, including both library build and install directories.
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
# NanoKVM support 说明
|
||||
|
||||
## 环境准备
|
||||
1. NanoKVM-Lite/Full/PCIe 以SG2002为主控芯片,support 部分的工程在[MaixCDK](https://github.com/sipeed/MaixCDK) 框架下编译。编译前,请保证`MaixCDK`环境已正确配置,配置教程点击[这里](https://github.com/sipeed/MaixCDK/blob/main/docs/doc_zh/README.md)
|
||||
|
||||
NanoKVM-Lite/Full/PCIe 以 SG2002 为主控芯片。系统程序和 `libkvm.so` 包装层借助
|
||||
[MaixCDK](https://github.com/sipeed/MaixCDK) 编译,`libkvm_mmf.so` 使用该目录中的
|
||||
Sophgo 源码通过 CMake 独立编译。请先配置并激活
|
||||
[MaixCDK 环境](https://github.com/sipeed/MaixCDK/blob/main/docs/doc_zh/README.md),
|
||||
将 RISC-V musl 工具链加入 `PATH`。以下命令在 `support/sg2002/` 下执行。
|
||||
也可以在仓库根目录执行 `make vision`,通过 Docker 构建视频库。
|
||||
`make app` 使用 `server/dl_lib/` 中的预编译库构建服务器。
|
||||
|
||||
## kvm_system 编译说明
|
||||
|
||||
@@ -11,16 +18,17 @@
|
||||
2. 修改 `./build` 中 `MAIXCDK_PATH` 和 `NanoKVM_PATH`的路径
|
||||
3. 执行 `./build kvm_system` 编译 kvm_system
|
||||
4. 使用 `scp ./kvm_system/dist/kvm_system_release/kvm_system root@192.168.x.x:/kvmapp/kvm_system` 拷贝入 NanoKVM 测试
|
||||
5. 使用 `./build add_to_kvmapp` 可以将可执行文件放入 `/kvmapp` 安装包内
|
||||
5. 使用 `./build kvm_system add_to_kvmapp` 将可执行文件放入仓库的 `kvmapp/` 目录
|
||||
6. 使用 `./build kvm_system clean` 可以清除 kvm_system 的编译
|
||||
|
||||
## kvm_vision 编译说明
|
||||
|
||||
> `kvm_vision` 是 NanoKVM 图像获取编码子系统的统称,使用MaixCDK编译出动态库供Go调用,使用 `kvm_vision_test` 编译和测试动态库
|
||||
> `kvm_vision` 先构建 `libkvm_mmf.so`,再构建 MaixCDK 包装层 `libkvm.so` 和 `kvm_vision_test` 示例程序。
|
||||
|
||||
1. 编译前,请保证上述 1.`MaixCDK`环境已正确配置
|
||||
2. 修改 `./build` 中 `MAIXCDK_PATH` 和 `NanoKVM_PATH`的路径
|
||||
3. 执行 `./build kvm_vision` 编译 kvm_vision_test
|
||||
4. 可使用 `kvm_vision_test/dist/kvm_vision_test_release/dl_lib/` 中的动态库测试
|
||||
5. 使用 `./build add_to_kvmapp` 可以将动态库放入 `/kvmapp` 安装包内
|
||||
6. 使用 `./build kvm_vision clean` 可以清除 kvm_system 的编译
|
||||
3. 执行 `./build kvm_vision` 编译两份库和示例程序。库的编译和安装目录分别为 `kvm_vision_test/build/kvm_mmf-build/`、`kvm_vision_test/build/kvm_mmf-install/`。
|
||||
4. 构建后的动态库位于 `kvm_vision_test/dist/kvm_vision_test_release/dl_lib/`。
|
||||
5. 执行 `./build kvm_vision add_to_kvmapp`,将库放入仓库的 `kvmapp/server/dl_lib/`。
|
||||
6. 修改视频库接口后,用同一批构建的两份库更新 `server/dl_lib/libkvm.so` 和 `server/dl_lib/libkvm_mmf.so`。服务器直接使用这些预编译库,编译步骤见[服务器说明](../../server/README_ZH.md#编译部署)。
|
||||
7. 执行 `./build kvm_vision clean` 清理构建产物,包括上述库的编译和安装目录。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
list(APPEND ADD_REQUIREMENTS vision basic peripheral)
|
||||
list(APPEND ADD_REQUIREMENTS kvm_mmf basic peripheral)
|
||||
list(APPEND ADD_INCLUDE "include")
|
||||
list(APPEND ADD_LINK_DEFINITIONS_PRIVATE -Wl,--as-needed)
|
||||
|
||||
|
||||
@@ -1,79 +1,43 @@
|
||||
|
||||
|
||||
#ifndef KVM_VISION_H_
|
||||
#define KVM_VISION_H_
|
||||
|
||||
#include "kvm_mmf.hpp"
|
||||
#include "maix_camera.hpp"
|
||||
#include "maix_basic.hpp"
|
||||
|
||||
#include "maix_i2c.hpp"
|
||||
#include <stdint.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>
|
||||
#include <time.h>
|
||||
|
||||
// #include "kvm_mmf.hpp"
|
||||
#define IMG_BUFFER_FULL -3
|
||||
#define IMG_VENC_ERROR -2
|
||||
#define IMG_NOT_EXIST -1
|
||||
#define IMG_MJPEG_TYPE 0
|
||||
#define IMG_H264_TYPE_IF 3
|
||||
#define IMG_H264_TYPE_PF 4
|
||||
|
||||
#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
|
||||
typedef struct {
|
||||
uint8_t *data;
|
||||
uint32_t size;
|
||||
uint64_t timestamp;
|
||||
uint64_t token;
|
||||
uint8_t codec;
|
||||
} kvmv_frame_t;
|
||||
|
||||
#define NORMAL_RES 0
|
||||
#define NEW_RES 1
|
||||
#define UNSUPPORT_RES 2
|
||||
#define UNKNOWN_RES 3
|
||||
#define ERROR_RES 4
|
||||
void kvmv_init(uint8_t debug_info);
|
||||
void kvmv_deinit(void);
|
||||
|
||||
void kvmv_init(uint8_t _debug_info_en = 0);
|
||||
void set_venc_auto_recyc(uint8_t _enable);
|
||||
/**********************************************************************************
|
||||
* @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
|
||||
-7: HDMI INPUT RES ERROR
|
||||
-6: Unsupported resolution, please modify it in the host settings.
|
||||
-5: Retrieving image, please wait
|
||||
-4: Modifying image resolution, please wait
|
||||
-3: img buffer full
|
||||
-2: VENC Errorl
|
||||
-1: No images were acquired
|
||||
0: Acquire MJPEG encoded images
|
||||
1: Acquire H264 encoded images(SPS)[Deprecated]
|
||||
2: Acquire H264 encoded images(PPS)[Deprecated]
|
||||
3: Acquire H264 encoded images(I)
|
||||
4: Acquire H264 encoded images(P)
|
||||
5: IMG not changed
|
||||
**********************************************************************************/
|
||||
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 free_kvmv_data(uint8_t ** _pp_kvm_data);
|
||||
void free_all_kvmv_data();
|
||||
void set_h264_gop(uint8_t _gop);
|
||||
void set_frame_detact(uint8_t _frame_detact);
|
||||
void kvmv_deinit();
|
||||
uint8_t kvmv_hdmi_control(uint8_t _en);
|
||||
// type: 0 = MJPEG, 1 = H.264. A zero width/height follows the input size.
|
||||
// quality: MJPEG 50..100, H.264 500..10000 Kbps. fps: 1..60.
|
||||
// Returns -7 input error, -6 unsupported size, -5 busy, -4 reconfiguring,
|
||||
// -3 allocation failure, -2 encoder error, -1 no frame, 0 JPEG, 3 IDR,
|
||||
// 4 P-frame, or 5 unchanged JPEG. Release every successful frame once.
|
||||
int kvmv_read_img(uint16_t width, uint16_t height, uint8_t type,
|
||||
uint16_t quality, uint16_t fps, kvmv_frame_t *frame);
|
||||
// Frees the copied bytes and releases the encoder lease. Safe after shutdown.
|
||||
void kvmv_release_frame(kvmv_frame_t *frame);
|
||||
void kvmv_stop_stream(uint8_t type);
|
||||
void set_h264_gop(uint8_t gop);
|
||||
void set_frame_detact(uint8_t interval);
|
||||
uint8_t kvmv_hdmi_control(uint8_t enable);
|
||||
uint8_t kvmv_hdmi_signal_active(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -1,45 +1,29 @@
|
||||
/**
|
||||
* 待解决的问题:
|
||||
* // 分辨率跟随输出
|
||||
* // 自动切换分辨率
|
||||
* // HDMI分辨率问题
|
||||
* // H264输入空图,VENC容易炸问题
|
||||
* // MJPEG/H264切换时卡退
|
||||
* // 再加一条手动清空全部内存
|
||||
* // deinit时free全部内存
|
||||
* // free 错内存时会炸的问题
|
||||
*/
|
||||
#include "kvm_vision.h"
|
||||
#include "maix_basic.hpp"
|
||||
#include "maix_i2c.hpp"
|
||||
#include "vi_state_shared.hpp"
|
||||
#include "internal/vi_state_writer.hpp"
|
||||
#include "kvm_mmf.hpp"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdarg>
|
||||
#include <mutex>
|
||||
#include <pthread.h>
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#define default_venc_chn 1
|
||||
|
||||
#define VENC_MJPEG 0
|
||||
#define VENC_H264 1
|
||||
|
||||
#define KVMV_MAX_TRY_NUM 2
|
||||
#define vi_min_width 32
|
||||
#define vi_min_height 3
|
||||
#define vi_max_width 1920
|
||||
#define vi_max_height 1080
|
||||
|
||||
#define Farame_sample_size 76800 // 320*240
|
||||
|
||||
#define default_vi_width 1920
|
||||
#define default_vi_height 1080
|
||||
#define default_vpss_width 1920
|
||||
#define default_vpss_height 1080
|
||||
#define default_venc_type VENC_MJPEG
|
||||
#define default_mjpeg_qlty 60
|
||||
#define default_h264_qlty 1000
|
||||
#define default_h264_gop 30
|
||||
#define fresh_frame_discard_count 5
|
||||
|
||||
#define kvmv_data_buffer_size 4
|
||||
#define Try_rounds_HDMI_err_res 5
|
||||
|
||||
#define vi_width_path "/kvmapp/kvm/width"
|
||||
@@ -56,7 +40,9 @@
|
||||
#define LT6911_READ 0xFF
|
||||
#define LT6911_WRITE 0x00
|
||||
|
||||
pthread_mutex_t vi_mutex;
|
||||
std::mutex capture_mutex[2];
|
||||
pthread_t detection_thread, watchdog_thread;
|
||||
bool detection_started = false, watchdog_started = false;
|
||||
pthread_mutex_t hdmi_signal_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static char NanoKVM_edit[] = {
|
||||
@@ -75,56 +61,30 @@ using namespace maix::sys;
|
||||
using namespace maix::peripheral;
|
||||
i2c::I2C LT6911_i2c(4, i2c::Mode::MASTER);
|
||||
|
||||
typedef struct {
|
||||
uint16_t vi_width = default_vi_width;
|
||||
uint16_t vi_height = default_vi_height;
|
||||
uint16_t vpss_width = default_vpss_width;
|
||||
uint16_t vpss_height = default_vpss_height;
|
||||
uint8_t venc_type;
|
||||
uint16_t qlty;
|
||||
uint8_t stream_stop;
|
||||
uint8_t frame_detact = 0;
|
||||
uint8_t display;
|
||||
uint8_t reinit_flag = 1;
|
||||
uint8_t reopen_cam_flag = 0;
|
||||
uint8_t hdmi_cable_state = 0;
|
||||
uint8_t try_exit_thread = 0;
|
||||
uint8_t thread_is_running = 0;
|
||||
uint8_t Auto_res = 0;
|
||||
enum ResolutionState { NORMAL_RES, NEW_RES, UNSUPPORT_RES, UNKNOWN_RES, ERROR_RES };
|
||||
|
||||
struct {
|
||||
std::atomic<uint16_t> vi_width{default_vi_width};
|
||||
std::atomic<uint16_t> vi_height{default_vi_height};
|
||||
uint8_t frame_detact = 0;
|
||||
std::atomic<bool> reopen_cam_flag{false};
|
||||
std::atomic<bool> try_exit_thread{false};
|
||||
uint8_t hdmi_version = 0;
|
||||
uint8_t hw_version = 0;
|
||||
uint8_t hdmi_stop_flag = 0;
|
||||
uint8_t hdmi_reading_flag = 0;
|
||||
std::atomic<bool> hdmi_stop_flag{false};
|
||||
std::atomic<bool> hdmi_reading_flag{false};
|
||||
uint8_t hdmi_mode = 0;
|
||||
uint8_t hdmi_res_type = 0;
|
||||
uint8_t hdmi_res_err = 0;
|
||||
std::atomic<uint8_t> hdmi_res_type{NORMAL_RES};
|
||||
std::atomic<uint8_t> hdmi_res_err{NORMAL_RES};
|
||||
uint8_t hdmi_try_rounds = 0;
|
||||
uint8_t vi_detect_state = 0;
|
||||
uint8_t venc_auto_recyc = 0;
|
||||
uint8_t fresh_frame_count = 0;
|
||||
} kvmv_cfg_t;
|
||||
std::atomic<uint8_t> vi_detect_state{0};
|
||||
} kvmv_cfg;
|
||||
|
||||
typedef struct {
|
||||
uint8_t* p_img_data = NULL;
|
||||
uint8_t img_data_type = 0;
|
||||
uint32_t img_data_size = 0;
|
||||
} kvmv_data_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t mmf_venc_chn;
|
||||
uint8_t enc_h264_running;
|
||||
uint8_t enc_h264_init;
|
||||
mmf_venc_cfg_t kvm_venc_cfg;
|
||||
} kvm_venc_t;
|
||||
|
||||
camera::Camera *cam = new camera::Camera(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
|
||||
// camera::Camera *cam = new camera::Camera(320, 240, image::FMT_RGB888);
|
||||
|
||||
kvmv_cfg_t kvmv_cfg;
|
||||
|
||||
kvmv_data_t kvmv_data_buffer[kvmv_data_buffer_size];
|
||||
|
||||
uint8_t kvmv_data_buffer_index = 0;
|
||||
struct {
|
||||
uint64_t hash = 0;
|
||||
uint8_t count = 0;
|
||||
bool unchanged = false;
|
||||
} jpeg_detection;
|
||||
|
||||
uint8_t debug_en = 0;
|
||||
uint8_t last_vi_state_code = 0;
|
||||
@@ -200,16 +160,13 @@ static void set_hdmi_capture_enabled(uint8_t enabled)
|
||||
pthread_mutex_unlock(&hdmi_signal_mutex);
|
||||
}
|
||||
|
||||
static void set_hdmi_detection_state(uint8_t active)
|
||||
{
|
||||
__atomic_store_n(&kvmv_cfg.hdmi_cable_state, active != 0 ? 1 : 0, __ATOMIC_RELEASE);
|
||||
set_hdmi_signal_state(active);
|
||||
}
|
||||
|
||||
void debug(const char *format, ...)
|
||||
{
|
||||
if(debug_en){
|
||||
printf(format);
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
vprintf(format, args);
|
||||
va_end(args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,17 +176,10 @@ uint8_t refresh_vi_state()
|
||||
// that the publication cadence measures wall-clock time, not read time plus it.
|
||||
last_vi_state_refresh_ms = vi_state_shared::monotonic_ms();
|
||||
last_vi_state_code = vi_state_shared::refresh();
|
||||
set_hdmi_detection_state(last_vi_state_code == 1 || last_vi_state_code >= 3);
|
||||
set_hdmi_signal_state(last_vi_state_code == 1 || last_vi_state_code >= 3);
|
||||
return last_vi_state_code;
|
||||
}
|
||||
|
||||
uint8_t to_roll(int8_t _input)
|
||||
{
|
||||
if(_input < 0) return _input + kvmv_data_buffer_size;
|
||||
if(_input >= kvmv_data_buffer_size) return _input - kvmv_data_buffer_size;
|
||||
return _input;
|
||||
}
|
||||
|
||||
int maxmin_data(int _max, int _min, int _data)
|
||||
{
|
||||
if(_data > _max) return _max;
|
||||
@@ -237,17 +187,6 @@ int maxmin_data(int _max, int _min, int _data)
|
||||
return _data;
|
||||
}
|
||||
|
||||
kvmv_data_t* get_save_buffer()
|
||||
{
|
||||
kvmv_data_buffer_index = to_roll(kvmv_data_buffer_index + 1);
|
||||
// debug("[kvmv]kvmv_data_buffer_index = %d\n", kvmv_data_buffer_index);
|
||||
// debug("[kvmv]kvmv_data_buffer.p_img_data = %d\n", kvmv_data_buffer[kvmv_data_buffer_index].p_img_data);
|
||||
if(kvmv_data_buffer[kvmv_data_buffer_index].p_img_data == NULL){
|
||||
return &kvmv_data_buffer[kvmv_data_buffer_index];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// ====HDMI RES==================================================
|
||||
|
||||
uint16_t hdmi_res_list[][2] = {
|
||||
@@ -435,12 +374,12 @@ int get_manual_resolution(void)
|
||||
// res change ?
|
||||
if(kvmv_cfg.vi_width != tmp_width){
|
||||
kvmv_cfg.vi_width = tmp_width;
|
||||
printf("[kvmk] get new width = %d\n", kvmv_cfg.vi_width);
|
||||
printf("[kvmk] get new width = %d\n", kvmv_cfg.vi_width.load());
|
||||
res = 1;
|
||||
}
|
||||
if(kvmv_cfg.vi_height != tmp_height){
|
||||
kvmv_cfg.vi_height = tmp_height;
|
||||
printf("[kvmk] get new height = %d\n", kvmv_cfg.vi_height);
|
||||
printf("[kvmk] get new height = %d\n", kvmv_cfg.vi_height.load());
|
||||
res = 1;
|
||||
}
|
||||
return res;
|
||||
@@ -452,12 +391,12 @@ uint8_t auto_try_res()
|
||||
uint8_t err_code;
|
||||
uint8_t auto_trying_times = 0;
|
||||
|
||||
for (auto_trying_times = 0; auto_trying_times < sizeof(hdmi_res_list)/4; auto_trying_times++){
|
||||
for (auto_trying_times = 0; auto_trying_times < sizeof(hdmi_res_list)/4 && !kvmv_cfg.try_exit_thread.load(); auto_trying_times++){
|
||||
err_code = refresh_vi_state();
|
||||
switch(err_code){
|
||||
case 0:
|
||||
// shouldn't be possible to run here
|
||||
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
|
||||
kvm_mmf::restart();
|
||||
printf("[kvmv] VI not init\n");
|
||||
break;
|
||||
case 1:
|
||||
@@ -484,8 +423,8 @@ uint8_t auto_try_res()
|
||||
|
||||
kvmv_cfg.vi_width = hdmi_res_list[auto_trying_times][0];
|
||||
kvmv_cfg.vi_height = hdmi_res_list[auto_trying_times][1];
|
||||
printf("[kvmv] restart cam...\n");
|
||||
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
|
||||
printf("[kvmv] restart video...\n");
|
||||
kvm_mmf::restart();
|
||||
time::sleep_ms(50);
|
||||
break;
|
||||
case 7: // Unknown reason
|
||||
@@ -1121,7 +1060,7 @@ void* watchdog_sf_feed(void * arg)
|
||||
{
|
||||
while(true)
|
||||
{
|
||||
if(kvmv_cfg.try_exit_thread == 1)
|
||||
if(kvmv_cfg.try_exit_thread.load() == 1)
|
||||
break;
|
||||
time::sleep_ms(500);
|
||||
if (watchdog_sf_is_open()){
|
||||
@@ -1133,6 +1072,7 @@ void* watchdog_sf_feed(void * arg)
|
||||
vision_update_watchdog();
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void get_hdmi_version()
|
||||
@@ -1172,6 +1112,16 @@ void get_hdmi_version()
|
||||
}
|
||||
}
|
||||
|
||||
static uint8_t update_hdmi_resolution()
|
||||
{
|
||||
uint16_t width = kvmv_cfg.vi_width;
|
||||
uint16_t height = kvmv_cfg.vi_height;
|
||||
uint8_t result = lt6911_get_csi_res(&width, &height);
|
||||
kvmv_cfg.vi_width = width;
|
||||
kvmv_cfg.vi_height = height;
|
||||
return result;
|
||||
}
|
||||
|
||||
void* vi_subsystem_detection(void * arg)
|
||||
{
|
||||
uint64_t __attribute__((unused)) int_time;
|
||||
@@ -1184,7 +1134,6 @@ void* vi_subsystem_detection(void * arg)
|
||||
uint8_t falling_times = 0;
|
||||
uint8_t cam_need_restart = 0;
|
||||
uint8_t auto_change_mode = 0;
|
||||
kvmv_cfg.thread_is_running = 1;
|
||||
if(access("/proc/lt_int", F_OK) != 0){
|
||||
time::sleep_ms(10);
|
||||
debug("[hdmi]/proc/lt_int not ok\n");
|
||||
@@ -1196,7 +1145,7 @@ void* vi_subsystem_detection(void * arg)
|
||||
last_vi_state_refresh_ms = vi_state_shared::monotonic_ms() - vi_state_publish_interval_ms;
|
||||
while(true)
|
||||
{
|
||||
if(kvmv_cfg.try_exit_thread == 1)
|
||||
if(kvmv_cfg.try_exit_thread.load() == 1)
|
||||
break;
|
||||
|
||||
uint8_t get_new_hdmi_mode = get_hdmi_mode();
|
||||
@@ -1258,8 +1207,8 @@ void* vi_subsystem_detection(void * arg)
|
||||
if(lt6911_get_hdmi_res()){
|
||||
// hdmi get res
|
||||
debug("[hdmi] C HDMI cable insertion!\n");
|
||||
set_hdmi_detection_state(1);
|
||||
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
|
||||
set_hdmi_signal_state(1);
|
||||
kvmv_cfg.hdmi_res_type = update_hdmi_resolution();
|
||||
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
|
||||
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
|
||||
/* Move HDMI resolution modification directly
|
||||
@@ -1270,7 +1219,7 @@ void* vi_subsystem_detection(void * arg)
|
||||
} else {
|
||||
// HDMI res = 0*0/x*0
|
||||
debug("[hdmi] C HDMI cable unplugged!\n");
|
||||
set_hdmi_detection_state(0);
|
||||
set_hdmi_signal_state(0);
|
||||
}
|
||||
lt6911_disable();
|
||||
}
|
||||
@@ -1283,8 +1232,8 @@ void* vi_subsystem_detection(void * arg)
|
||||
if(lt6911_get_hdmi_res()){
|
||||
// hdmi get res
|
||||
debug("[hdmi] UXC HDMI cable insertion!\n");
|
||||
set_hdmi_detection_state(1);
|
||||
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
|
||||
set_hdmi_signal_state(1);
|
||||
kvmv_cfg.hdmi_res_type = update_hdmi_resolution();
|
||||
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
|
||||
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
|
||||
/* Move HDMI resolution modification directly
|
||||
@@ -1295,7 +1244,7 @@ void* vi_subsystem_detection(void * arg)
|
||||
} else {
|
||||
// HDMI res = 0*0/x*0
|
||||
debug("[hdmi] UXC HDMI cable unplugged!\n");
|
||||
set_hdmi_detection_state(0);
|
||||
set_hdmi_signal_state(0);
|
||||
}
|
||||
lt6911_disable();
|
||||
}
|
||||
@@ -1308,8 +1257,8 @@ void* vi_subsystem_detection(void * arg)
|
||||
if(lt6911_get_hdmi_res()){
|
||||
// hdmi get res
|
||||
debug("[hdmi] D HDMI cable insertion!\n");
|
||||
set_hdmi_detection_state(1);
|
||||
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
|
||||
set_hdmi_signal_state(1);
|
||||
kvmv_cfg.hdmi_res_type = update_hdmi_resolution();
|
||||
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
|
||||
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
|
||||
/* Move HDMI resolution modification directly
|
||||
@@ -1320,7 +1269,7 @@ void* vi_subsystem_detection(void * arg)
|
||||
} else {
|
||||
// HDMI res = 0*0/x*0
|
||||
debug("[hdmi] D HDMI cable unplugged!\n");
|
||||
set_hdmi_detection_state(0);
|
||||
set_hdmi_signal_state(0);
|
||||
}
|
||||
lt6911_disable();
|
||||
}
|
||||
@@ -1392,8 +1341,8 @@ void* vi_subsystem_detection(void * arg)
|
||||
if (kvmv_cfg.vi_detect_state == 1){
|
||||
// detect_res
|
||||
if (get_manual_resolution()) {
|
||||
debug("[kvmv] restart cam...\n");
|
||||
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
|
||||
debug("[kvmv] restart video...\n");
|
||||
kvm_mmf::restart();
|
||||
}
|
||||
|
||||
// Sample after a manual resolution change.
|
||||
@@ -1442,554 +1391,121 @@ void* vi_subsystem_detection(void * arg)
|
||||
|
||||
time::sleep_ms(10);
|
||||
}
|
||||
kvmv_cfg.thread_is_running = 0;
|
||||
}
|
||||
|
||||
int sync_vi_res()
|
||||
{
|
||||
int res = 0;
|
||||
uint8_t RW_Data[35];
|
||||
FILE *fp;
|
||||
int file_size;
|
||||
uint16_t tmp16;
|
||||
|
||||
// vi_width:
|
||||
if (access(vi_width_path, F_OK) != 0){
|
||||
kvmv_cfg.vi_width = default_vi_width;
|
||||
kvmv_cfg.vi_height = default_vi_height;
|
||||
res = -1;
|
||||
return res;
|
||||
} else {
|
||||
fp = fopen(vi_width_path, "r");
|
||||
fseek(fp, 0, SEEK_END);
|
||||
file_size = ftell(fp);
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
fread(RW_Data, sizeof(char), file_size, fp);
|
||||
fclose(fp);
|
||||
RW_Data[file_size] = 0;
|
||||
tmp16 = atoi((char*)RW_Data);
|
||||
if(tmp16 != kvmv_cfg.vi_width){
|
||||
kvmv_cfg.vi_width = tmp16;
|
||||
debug("[hdmi] Get new HDMI width = %d\r\n", kvmv_cfg.vi_width);
|
||||
res = 1;
|
||||
}
|
||||
}
|
||||
// vi_height:
|
||||
if (access(vi_height_path, F_OK) != 0){
|
||||
kvmv_cfg.vi_height = default_vi_height;
|
||||
res = -1;
|
||||
return res;
|
||||
} else {
|
||||
fp = fopen(vi_height_path, "r");
|
||||
fseek(fp, 0, SEEK_END);
|
||||
file_size = ftell(fp);
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
fread(RW_Data, sizeof(char), file_size, fp);
|
||||
fclose(fp);
|
||||
RW_Data[file_size] = 0;
|
||||
tmp16 = atoi((char*)RW_Data);
|
||||
if(tmp16 != kvmv_cfg.vi_height){
|
||||
kvmv_cfg.vi_height = tmp16;
|
||||
debug("[hdmi] Get new HDMI height = %d\r\n", kvmv_cfg.vi_height);
|
||||
res = 1;
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
uint8_t frame_changed(image::Image *raw)
|
||||
{
|
||||
static int raw_size = 0;
|
||||
static uint8_t Farame_sample[Farame_sample_size] = {0};
|
||||
uint8_t ret = 0;
|
||||
uint8_t sample_byte;
|
||||
if(raw->data_size() != raw_size){
|
||||
raw_size = raw->data_size();
|
||||
ret = 1;
|
||||
}
|
||||
int Detection_Pixel_Interval = raw_size/(Farame_sample_size*1.5);
|
||||
for(int i = 0; i < Farame_sample_size; i ++){
|
||||
// printf("[kvmv] i = %d\n", i);
|
||||
if(i >= raw_size){
|
||||
ret = 0;
|
||||
}
|
||||
sample_byte = *(uint8_t*)(raw->data()+(i*Detection_Pixel_Interval));
|
||||
if(sample_byte != Farame_sample[i]){
|
||||
Farame_sample[i] = sample_byte;
|
||||
ret = 1;
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
bool jpg_dump(kvmv_data_t* dump_to, image::Image *raw)
|
||||
{
|
||||
if(dump_to == NULL || raw == NULL || raw->data() == NULL || raw->data_size() == 0){
|
||||
return false;
|
||||
}
|
||||
dump_to->p_img_data = (uint8_t *)malloc(raw->data_size());
|
||||
if(dump_to->p_img_data == NULL){
|
||||
dump_to->img_data_size = 0;
|
||||
dump_to->img_data_type = 0;
|
||||
return false;
|
||||
}
|
||||
dump_to->img_data_size = raw->data_size();
|
||||
dump_to->img_data_type = VENC_MJPEG;
|
||||
memcpy(dump_to->p_img_data, (uint8_t *)raw->data(), raw->data_size());
|
||||
return true;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
uint8_t kvmvenc_gop = default_h264_gop;
|
||||
kvm_venc_t kvm_venc;
|
||||
mmf_venc_cfg_t cfg;
|
||||
void init_venc_h264(uint16_t _width, uint16_t _height, uint16_t _qlty)
|
||||
|
||||
void set_h264_gop(uint8_t gop)
|
||||
{
|
||||
cfg.type = 2; //1, h265, 2, h264
|
||||
cfg.w = _width;
|
||||
cfg.h = _height;
|
||||
cfg.fmt = mmf_invert_format_to_mmf(image::Format::FMT_YVU420SP);
|
||||
cfg.jpg_quality = 0; // unused
|
||||
cfg.gop = kvmvenc_gop;
|
||||
cfg.intput_fps = 60;
|
||||
cfg.output_fps = 60;
|
||||
cfg.bitrate = _qlty; // 码率
|
||||
|
||||
kvm_venc.mmf_venc_chn = default_venc_chn;
|
||||
kvm_venc.enc_h264_init = 0;
|
||||
kvm_venc.kvm_venc_cfg = cfg;
|
||||
|
||||
// if(mmf_vdec_is_used(kvm_venc.mmf_venc_chn)){
|
||||
mmf_del_venc_channel(kvm_venc.mmf_venc_chn);
|
||||
// }
|
||||
if (0 != mmf_add_venc_channel(kvm_venc.mmf_venc_chn, &kvm_venc.kvm_venc_cfg)) {
|
||||
err::check_raise(err::ERR_RUNTIME, "mmf venc init failed!");
|
||||
}
|
||||
kvm_venc.enc_h264_init = 1;
|
||||
|
||||
// init_kvm_h264_stream(&kvm_h264_stream, mmf_stream_buf);
|
||||
// init_h264_stream_struct(&kvm_h264_stream);
|
||||
__atomic_store_n(&kvmvenc_gop, (uint8_t)maxmin_data(100, 1, gop), __ATOMIC_RELEASE);
|
||||
}
|
||||
|
||||
int h264_stream_dump(kvmv_data_t* dump_to, mmf_stream_t* dump_from)
|
||||
void set_frame_detact(uint8_t interval)
|
||||
{
|
||||
static int8_t I_Frame_index = -1;
|
||||
// debug("[kvmv]dump_from->count = %d\n", dump_from->count);
|
||||
if (dump_from->count == 3) {
|
||||
std::lock_guard<std::mutex> lock(capture_mutex[VENC_MJPEG]);
|
||||
kvmv_cfg.frame_detact = maxmin_data(100, 0, interval);
|
||||
jpeg_detection = {};
|
||||
}
|
||||
|
||||
dump_to->p_img_data = (uint8_t *)malloc(dump_from->data_size[0]+dump_from->data_size[1]+dump_from->data_size[2]);
|
||||
dump_to->img_data_size = dump_from->data_size[0]+dump_from->data_size[1]+dump_from->data_size[2];
|
||||
dump_to->img_data_type = IMG_H264_TYPE_IF;
|
||||
memcpy(dump_to->p_img_data, dump_from->data[0], dump_from->data_size[0]);
|
||||
memcpy(dump_to->p_img_data+dump_from->data_size[0], dump_from->data[1], dump_from->data_size[1]);
|
||||
memcpy(dump_to->p_img_data+dump_from->data_size[0]+dump_from->data_size[1], dump_from->data[2], dump_from->data_size[2]);
|
||||
|
||||
debug("[kvmv]SPS size = %d\n", dump_from->data_size[0]);
|
||||
debug("[kvmv]PPS size = %d\n", dump_from->data_size[1]);
|
||||
debug("[kvmv]I-Frame size = %d\n", dump_from->data_size[2]);
|
||||
|
||||
return IMG_H264_TYPE_IF;
|
||||
|
||||
} else if (dump_from->count == 1) {
|
||||
// debug("[kvmv]dump P-Frame\r\n");
|
||||
I_Frame_index = -1;
|
||||
dump_to->p_img_data = (uint8_t *)malloc(dump_from->data_size[0]);
|
||||
dump_to->img_data_size = dump_from->data_size[0];
|
||||
dump_to->img_data_type = IMG_H264_TYPE_PF;
|
||||
memcpy(dump_to->p_img_data, dump_from->data[0], dump_from->data_size[0]);
|
||||
return IMG_H264_TYPE_PF;
|
||||
} else {
|
||||
debug("[kvmv]venc error!\r\n");
|
||||
return IMG_VENC_ERROR;
|
||||
void kvmv_init(uint8_t debug_info)
|
||||
{
|
||||
debug_en = debug_info != 0;
|
||||
kvmv_cfg.try_exit_thread = false;
|
||||
if (!kvm_mmf::initialize()) {
|
||||
fprintf(stderr, "[kvmv] video initialization failed\n");
|
||||
}
|
||||
if (!detection_started) {
|
||||
detection_started = pthread_create(&detection_thread, nullptr, vi_subsystem_detection, nullptr) == 0;
|
||||
}
|
||||
if (!watchdog_started) {
|
||||
watchdog_started = pthread_create(&watchdog_thread, nullptr, watchdog_sf_feed, nullptr) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
void set_h264_gop(uint8_t _gop)
|
||||
int kvmv_read_img(uint16_t width, uint16_t height, uint8_t type,
|
||||
uint16_t quality, uint16_t fps, kvmv_frame_t *out)
|
||||
{
|
||||
kvm_venc.enc_h264_init = 0; // call
|
||||
kvmvenc_gop = maxmin_data(100, 1, (int)_gop);
|
||||
debug("[kvmv] set_h264_gop = %d\n", kvmvenc_gop);
|
||||
}
|
||||
|
||||
void set_frame_detact(uint8_t _frame_detact)
|
||||
{
|
||||
uint8_t frame_detact = maxmin_data(100, 0, (int)_frame_detact);
|
||||
kvmv_cfg.frame_detact = frame_detact;
|
||||
debug("[kvmv] set_frame_detact = %d\n", kvmv_cfg.frame_detact);
|
||||
kvmv_cfg.stream_stop = 0;
|
||||
}
|
||||
|
||||
int8_t raw_to_h264(image::Image *raw, kvmv_data_t* ret_stream, uint16_t _qlty)
|
||||
{
|
||||
uint64_t __attribute__((unused)) start_time;
|
||||
uint64_t __attribute__((unused)) frame_time;
|
||||
int8_t ret = 0;
|
||||
static uint8_t P_Frame_Count = 0;
|
||||
// start_time = time::time_ms();
|
||||
// log::info("getimg: %d \r\n", (int)(time::time_ms()));
|
||||
mmf_stream_t _stream = {0};
|
||||
if(kvm_venc.enc_h264_init != 1 || raw->width() != kvm_venc.kvm_venc_cfg.w || raw->height() != kvm_venc.kvm_venc_cfg.h || _qlty != kvm_venc.kvm_venc_cfg.bitrate){
|
||||
debug("[kvmv]init_venc_h264 enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
|
||||
kvm_venc.enc_h264_init,
|
||||
raw->width(), kvm_venc.kvm_venc_cfg.w,
|
||||
raw->height(), kvm_venc.kvm_venc_cfg.h);
|
||||
|
||||
init_venc_h264(raw->width(), raw->height(), _qlty);
|
||||
debug("[kvmv]init_venc_h264 finish enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
|
||||
kvm_venc.enc_h264_init,
|
||||
raw->width(), kvm_venc.kvm_venc_cfg.w,
|
||||
raw->height(), kvm_venc.kvm_venc_cfg.h);
|
||||
// if(kvm_venc.enc_h264_init == 1){
|
||||
// init_venc_h264(raw->width(), raw->height(), _qlty);
|
||||
// } else {
|
||||
// init_venc_h264(default_vpss_width, default_vpss_height, default_h264_qlty);
|
||||
// }
|
||||
if (!out) return IMG_VENC_ERROR;
|
||||
*out = {};
|
||||
if (type > VENC_H264) return IMG_VENC_ERROR;
|
||||
std::lock_guard<std::mutex> lock(capture_mutex[type]);
|
||||
if (kvmv_cfg.try_exit_thread || kvmv_cfg.hdmi_stop_flag) return IMG_NOT_EXIST;
|
||||
if (kvmv_cfg.hdmi_res_err == ERROR_RES) return -7;
|
||||
if (kvmv_cfg.hdmi_res_type == UNSUPPORT_RES) return -6;
|
||||
if (kvmv_cfg.vi_detect_state == 1) return -4;
|
||||
if (kvmv_cfg.reopen_cam_flag.exchange(false) && !kvm_mmf::restart()) return IMG_VENC_ERROR;
|
||||
if (width == 0 || height == 0) {
|
||||
width = kvmv_cfg.vi_width;
|
||||
height = kvmv_cfg.vi_height;
|
||||
}
|
||||
// log::info("init(): %d \r\n", (int)(time::time_ms() - start_time));
|
||||
if (mmf_venc_push(kvm_venc.mmf_venc_chn, (uint8_t *)raw->data(), raw->width(), raw->height(), mmf_invert_format_to_mmf(raw->format()))) {
|
||||
mmf_del_venc_channel(kvm_venc.mmf_venc_chn);
|
||||
kvm_venc.enc_h264_init = 0;
|
||||
// rtmp->unlock();
|
||||
debug("[kvmv]mmf venc push failed!\n");
|
||||
// err::check_raise(err::ERR_RUNTIME, "mmf venc push failed!\r\n");
|
||||
return -1;
|
||||
}
|
||||
// log::info("push(): %d \r\n", (int)(time::time_ms() - start_time));
|
||||
if (mmf_venc_pop(kvm_venc.mmf_venc_chn, &_stream)) {
|
||||
// log::error("mmf_venc_pop failed\n");
|
||||
mmf_venc_free(kvm_venc.mmf_venc_chn);
|
||||
mmf_del_venc_channel(kvm_venc.mmf_venc_chn);
|
||||
kvm_venc.enc_h264_init = 0;
|
||||
debug("[kvmv]mmf venc push failed!\n");
|
||||
// rtmp->unlock();
|
||||
return -1;
|
||||
}
|
||||
// log::info("pop(): %d \r\n", (int)(time::time_ms() - start_time));
|
||||
ret = h264_stream_dump(ret_stream, &_stream);
|
||||
mmf_venc_free(kvm_venc.mmf_venc_chn);
|
||||
// log::info("dump(): %d \r\n", (int)(time::time_ms() - start_time));
|
||||
quality = type == VENC_H264 ? maxmin_data(10000, 500, quality) : maxmin_data(100, 50, quality);
|
||||
fps = maxmin_data(60, 1, fps);
|
||||
auto codec = static_cast<kvm_mmf::Codec>(type);
|
||||
kvm_mmf::Frame frame;
|
||||
int result = kvm_mmf::acquire(codec, width, height, quality, fps,
|
||||
__atomic_load_n(&kvmvenc_gop, __ATOMIC_ACQUIRE), &frame);
|
||||
if (result < 0) return result;
|
||||
|
||||
// debug("[kvmv]_stream.data[0][4] = %d;\n", _stream.data[0][4]);
|
||||
debug("[kvmv]Frame size = %d;\n", ret_stream->img_data_size);
|
||||
|
||||
if(ret == IMG_H264_TYPE_IF){
|
||||
debug("[kvmv]================ GOP = %d ================\n", kvm_venc.kvm_venc_cfg.gop);
|
||||
debug("[kvmv]SPS; PPS; I-Frame, I-Frame size = %d\n", ret_stream->img_data_size);
|
||||
P_Frame_Count = 0;
|
||||
|
||||
} else if(ret == IMG_H264_TYPE_PF){
|
||||
debug("[kvmv]P-Frame size = %d, P-count = %d\n", ret_stream->img_data_size, P_Frame_Count);
|
||||
P_Frame_Count++;
|
||||
}
|
||||
debug("[kvmv]dump ret = %d\n", ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
void kvmv_init(uint8_t _debug_info_en)
|
||||
{
|
||||
pthread_t thread;
|
||||
pthread_mutex_init(&vi_mutex, NULL);
|
||||
if(_debug_info_en == 0) debug_en = 0;
|
||||
else debug_en = 1;
|
||||
|
||||
// debug("[kvmv]kvmv_init - 1\r\n");
|
||||
|
||||
cam->hmirror(1);
|
||||
cam->vflip(1);
|
||||
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
|
||||
for(int i = 0; i < kvmv_data_buffer_size; i++){
|
||||
kvmv_data_buffer[i].p_img_data = NULL;
|
||||
}
|
||||
|
||||
kvmv_cfg.try_exit_thread = 0;
|
||||
// debug("[kvmv]kvmv_init - 2\r\n");
|
||||
|
||||
if(kvmv_cfg.thread_is_running == 1){
|
||||
debug("[kvmv]thread is running!\r\n");
|
||||
} else {
|
||||
if (0 != pthread_create(&thread, NULL, vi_subsystem_detection, NULL)) {
|
||||
debug("[kvmv]create vi_subsystem_detection thread failed!\r\n");
|
||||
// return -1;
|
||||
}
|
||||
|
||||
if (0 != pthread_create(&thread, NULL, watchdog_sf_feed, NULL)) {
|
||||
debug("[kvmv]create watchdog_sf_feed thread failed!\r\n");
|
||||
// return -1;
|
||||
}
|
||||
}
|
||||
// debug("[kvmv]kvmv_init - 3\r\n");
|
||||
}
|
||||
|
||||
uint8_t check_kvmv(uint8_t _try_num)
|
||||
{
|
||||
if(__atomic_load_n(&kvmv_cfg.hdmi_cable_state, __ATOMIC_ACQUIRE) == 0){
|
||||
debug("[kvmv]HDMI Cable not exist!\n");
|
||||
return 0;
|
||||
}
|
||||
if(_try_num >= KVMV_MAX_TRY_NUM){
|
||||
debug("[kvmv]try_num >= KVMV_MAX_TRY_NUM!\n");
|
||||
return 0;
|
||||
}
|
||||
// if(sync_vi_res() != 0){
|
||||
if(kvmv_cfg.reopen_cam_flag == 1){
|
||||
// vi size changed
|
||||
kvmv_cfg.reopen_cam_flag = 0;
|
||||
// cam->open(kvmv_cfg.vpss_width, kvmv_cfg.vpss_height, image::FMT_YVU420SP, 3);
|
||||
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
|
||||
debug("[kvmv]vi size changed, try again\n");
|
||||
return 1;
|
||||
}
|
||||
debug("[kvmv]just try again\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
void set_venc_auto_recyc(uint8_t _enable)
|
||||
{
|
||||
if(_enable) kvmv_cfg.venc_auto_recyc = 1;
|
||||
else kvmv_cfg.venc_auto_recyc = 0;
|
||||
}
|
||||
|
||||
/**********************************************************************************
|
||||
* @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
|
||||
-7: HDMI INPUT RES ERROR
|
||||
-6: Unsupported resolution, please modify it in the host settings.
|
||||
-5: Retrieving image, please wait
|
||||
-4: Modifying image resolution, please wait
|
||||
-3: img buffer full
|
||||
-2: VENC Errorl
|
||||
-1: No images were acquired
|
||||
0: Acquire MJPEG encoded images
|
||||
1: Acquire H264 encoded images(SPS)[Deprecated]
|
||||
2: Acquire H264 encoded images(PPS)[Deprecated]
|
||||
3: Acquire H264 encoded images(I)
|
||||
4: Acquire H264 encoded images(P)
|
||||
5: IMG not changed
|
||||
**********************************************************************************/
|
||||
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)
|
||||
{
|
||||
*_pp_kvm_data = NULL;
|
||||
*_p_kvmv_data_size = 0;
|
||||
static uint8_t frame_undetact_count = 0;
|
||||
// uint64_t __attribute__((unused)) start_time = time::time_ms();
|
||||
debug("[kvmv]kvmv_read_img type = %d...\n", _type);
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_REALTIME, &ts);
|
||||
ts.tv_sec += 1;
|
||||
// pthread_mutex_lock(&vi_mutex); // Add lock
|
||||
int mutex_res = pthread_mutex_timedlock(&vi_mutex, &ts);
|
||||
if(mutex_res != 0){
|
||||
return -5;
|
||||
}
|
||||
if (kvmv_cfg.hdmi_res_err == ERROR_RES){
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return -7;
|
||||
}
|
||||
if (kvmv_cfg.hdmi_res_type == UNSUPPORT_RES){
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return -6;
|
||||
}
|
||||
if (kvmv_cfg.vi_detect_state == 1){
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return -4;
|
||||
}
|
||||
uint8_t try_num = 0;
|
||||
do {
|
||||
if(kvmv_cfg.vpss_width != _width || kvmv_cfg.vpss_height != _height){
|
||||
if(_width == 0 || _height == 0){
|
||||
// Follow the HDMI output
|
||||
kvmv_cfg.vpss_width = kvmv_cfg.vi_width;
|
||||
kvmv_cfg.vpss_height = kvmv_cfg.vi_height;
|
||||
|
||||
if(kvmv_cfg.Auto_res == 0){
|
||||
kvmv_cfg.Auto_res = 1;
|
||||
cam->set_resolution(kvmv_cfg.vpss_width, kvmv_cfg.vpss_height);
|
||||
kvmv_cfg.reinit_flag = 1;
|
||||
}
|
||||
|
||||
} else {
|
||||
kvmv_cfg.Auto_res = 0;
|
||||
// Set the output
|
||||
kvmv_cfg.vpss_width = _width;
|
||||
kvmv_cfg.vpss_height = _height;
|
||||
|
||||
cam->set_resolution(kvmv_cfg.vpss_width, kvmv_cfg.vpss_height);
|
||||
kvmv_cfg.reinit_flag = 1;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
if (kvmv_cfg.reinit_flag == 1) {
|
||||
cam->hmirror(1);
|
||||
cam->vflip(1);
|
||||
kvmv_cfg.reinit_flag = 0;
|
||||
}
|
||||
// debug("[kvmv]befor read img: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
image::Image *img = cam->read();
|
||||
// debug("[kvmv]read img: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
|
||||
if(img != NULL){
|
||||
if(kvmv_cfg.fresh_frame_count != 0){
|
||||
// Do not restart VI after HDMI idle. Reopening the MMF
|
||||
// channel can exhaust the carveout heap when the detector
|
||||
// thread is also transitioning. Consume queued frames
|
||||
// instead; the camera buffer contains at most three frames.
|
||||
kvmv_cfg.fresh_frame_count--;
|
||||
delete img;
|
||||
continue;
|
||||
}
|
||||
|
||||
// frame detect
|
||||
if(_type == VENC_MJPEG && kvmv_cfg.frame_detact != 0){
|
||||
if(kvmv_cfg.stream_stop == 0){
|
||||
frame_undetact_count++;
|
||||
} else {
|
||||
frame_undetact_count = kvmv_cfg.frame_detact;
|
||||
}
|
||||
|
||||
if(frame_undetact_count == kvmv_cfg.frame_detact){
|
||||
frame_undetact_count = 0;
|
||||
|
||||
if(frame_changed(img) == 0){
|
||||
debug("[kvmv]frame not changed...\n");
|
||||
kvmv_cfg.stream_stop = 1;
|
||||
delete img;
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return 5;
|
||||
} else {
|
||||
kvmv_cfg.stream_stop = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete img;
|
||||
debug("[kvmv]can`t get img...\n");
|
||||
continue;
|
||||
// pthread_mutex_unlock(&vi_mutex);
|
||||
// return IMG_NOT_EXIST;
|
||||
}
|
||||
// debug("[kvmv]cheak img null?: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
|
||||
// img exist
|
||||
// Encode
|
||||
if(kvmv_cfg.venc_type == VENC_MJPEG && kvmv_cfg.venc_type != _type){
|
||||
if(kvmv_cfg.venc_auto_recyc == 1){
|
||||
mmf_enc_jpg_deinit(0);
|
||||
}
|
||||
kvm_venc.enc_h264_init = 1;
|
||||
}
|
||||
if(kvmv_cfg.venc_type == VENC_H264 && kvmv_cfg.venc_type != _type){
|
||||
if(kvmv_cfg.venc_auto_recyc == 1){
|
||||
mmf_del_venc_channel(kvm_venc.mmf_venc_chn);
|
||||
}
|
||||
kvm_venc.enc_h264_init = 0;
|
||||
}
|
||||
|
||||
kvmv_cfg.venc_type = _type;
|
||||
|
||||
if(kvmv_cfg.venc_type == VENC_MJPEG){
|
||||
kvmv_data_t* p_kvmv_data = get_save_buffer();
|
||||
if(p_kvmv_data == NULL){
|
||||
// buffer full
|
||||
delete img;
|
||||
debug("[kvmv]jpg buffer full\n");
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return IMG_BUFFER_FULL;
|
||||
}
|
||||
image::Image *jpg = img->to_jpeg(maxmin_data(99, 51, (int)_qlty));
|
||||
if(jpg == NULL || !jpg_dump(p_kvmv_data, jpg)){
|
||||
delete jpg;
|
||||
delete img;
|
||||
debug("[kvmv]failed to allocate jpg buffer\n");
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return IMG_BUFFER_FULL;
|
||||
}
|
||||
delete jpg;
|
||||
delete img;
|
||||
*_pp_kvm_data = p_kvmv_data->p_img_data;
|
||||
*_p_kvmv_data_size = p_kvmv_data->img_data_size;
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return IMG_MJPEG_TYPE;
|
||||
} else if (kvmv_cfg.venc_type == VENC_H264){
|
||||
int ret;
|
||||
kvmv_data_t* p_kvmv_data = get_save_buffer();
|
||||
if(p_kvmv_data == NULL){
|
||||
// buffer full
|
||||
delete img;
|
||||
*_pp_kvm_data = NULL;
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return IMG_BUFFER_FULL;
|
||||
}
|
||||
// debug("[kvmv]get_save_buffer: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
ret = raw_to_h264(img, p_kvmv_data, maxmin_data(10000, 500, (int)_qlty));
|
||||
// debug("[kvmv]venc raw_to_h264: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
delete img;
|
||||
*_pp_kvm_data = p_kvmv_data->p_img_data;
|
||||
*_p_kvmv_data_size = p_kvmv_data->img_data_size;
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return ret;
|
||||
}
|
||||
} while (check_kvmv(try_num++));
|
||||
// debug("[kvmv]return: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
*_pp_kvm_data = NULL;
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return IMG_NOT_EXIST;
|
||||
}
|
||||
|
||||
int free_kvmv_data(uint8_t ** _pp_kvm_data)
|
||||
{
|
||||
// debug("[kvmv]free_kvmv_data - 1\r\n");
|
||||
for(int i = 0; i < kvmv_data_buffer_size; i++){
|
||||
if(*_pp_kvm_data == kvmv_data_buffer[i].p_img_data){
|
||||
// debug("[kvmv]free buffer : %d\n", *_pp_kvm_data);
|
||||
if (*_pp_kvm_data != NULL){
|
||||
// debug("[kvmv]free_kvmv_data - 2\r\n");
|
||||
free(*_pp_kvm_data);
|
||||
// debug("[kvmv]free_kvmv_data - 3\r\n");
|
||||
kvmv_data_buffer[i].p_img_data = NULL;
|
||||
uint8_t _type = kvmv_data_buffer[i].img_data_type;
|
||||
return _type;
|
||||
} else {
|
||||
return IMG_NOT_EXIST;
|
||||
// Detect unchanged JPEGs without reading raw VPSS pixels.
|
||||
if (codec == kvm_mmf::MJPEG) {
|
||||
uint8_t interval = kvmv_cfg.frame_detact;
|
||||
if (interval == 0) {
|
||||
jpeg_detection = {};
|
||||
} else if (jpeg_detection.unchanged || ++jpeg_detection.count >= interval) {
|
||||
jpeg_detection.count = 0;
|
||||
uint64_t hash = 14695981039346656037ULL;
|
||||
for (uint32_t i = 0; i < frame.size; ++i) hash = (hash ^ frame.data[i]) * 1099511628211ULL;
|
||||
jpeg_detection.unchanged = hash == jpeg_detection.hash;
|
||||
jpeg_detection.hash = hash;
|
||||
if (jpeg_detection.unchanged) {
|
||||
kvm_mmf::release(codec, frame.token);
|
||||
free(frame.data);
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
return IMG_NOT_EXIST;
|
||||
|
||||
*out = {frame.data, frame.size, frame.timestamp, frame.token, type};
|
||||
return codec == kvm_mmf::MJPEG ? IMG_MJPEG_TYPE :
|
||||
(frame.keyframe ? IMG_H264_TYPE_IF : IMG_H264_TYPE_PF);
|
||||
}
|
||||
|
||||
void free_all_kvmv_data()
|
||||
void kvmv_release_frame(kvmv_frame_t *frame)
|
||||
{
|
||||
for(int i = 0; i < kvmv_data_buffer_size; i++){
|
||||
if(kvmv_data_buffer[i].p_img_data != NULL){
|
||||
free(kvmv_data_buffer[i].p_img_data);
|
||||
kvmv_data_buffer[i].p_img_data = NULL;
|
||||
}
|
||||
}
|
||||
if (!frame || !frame->data) return;
|
||||
kvm_mmf::release(static_cast<kvm_mmf::Codec>(frame->codec), frame->token);
|
||||
free(frame->data);
|
||||
*frame = {};
|
||||
}
|
||||
|
||||
void kvmv_stop_stream(uint8_t type)
|
||||
{
|
||||
if (type > VENC_H264) return;
|
||||
std::lock_guard<std::mutex> lock(capture_mutex[type]);
|
||||
kvm_mmf::stop(static_cast<kvm_mmf::Codec>(type));
|
||||
if (type == VENC_MJPEG) jpeg_detection = {};
|
||||
}
|
||||
|
||||
void kvmv_deinit()
|
||||
{
|
||||
set_hdmi_capture_enabled(0);
|
||||
pthread_mutex_destroy(&vi_mutex);
|
||||
kvmv_cfg.try_exit_thread = 1;
|
||||
cam->close();
|
||||
mmf_deinit();
|
||||
free_all_kvmv_data();
|
||||
kvmv_cfg.try_exit_thread = true;
|
||||
if (detection_started) { pthread_join(detection_thread, nullptr); detection_started = false; }
|
||||
if (watchdog_started) { pthread_join(watchdog_thread, nullptr); watchdog_started = false; }
|
||||
std::unique_lock<std::mutex> h264(capture_mutex[VENC_H264], std::defer_lock);
|
||||
std::unique_lock<std::mutex> mjpeg(capture_mutex[VENC_MJPEG], std::defer_lock);
|
||||
std::lock(h264, mjpeg);
|
||||
kvm_mmf::deinitialize();
|
||||
jpeg_detection = {};
|
||||
}
|
||||
|
||||
uint8_t kvmv_hdmi_control(uint8_t _en)
|
||||
{
|
||||
if(_en == 0){
|
||||
set_hdmi_capture_enabled(0);
|
||||
kvm_mmf::stop(kvm_mmf::H264);
|
||||
kvm_mmf::stop(kvm_mmf::MJPEG);
|
||||
}
|
||||
|
||||
if(kvmv_cfg.hw_version == 0){
|
||||
@@ -2030,11 +1546,6 @@ uint8_t kvmv_hdmi_control(uint8_t _en)
|
||||
} else {
|
||||
kvmv_cfg.hdmi_stop_flag = 0;
|
||||
system("echo 1 > /sys/class/gpio/gpio451/value");
|
||||
|
||||
// Keep the existing VI channel and consume queued frames after idle.
|
||||
// Reopening it here or from kvmv_read_img can exhaust the carveout
|
||||
// heap while the HDMI detector is transitioning.
|
||||
kvmv_cfg.fresh_frame_count = fresh_frame_discard_count;
|
||||
set_hdmi_capture_enabled(1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,66 +1,71 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
list(APPEND ADD_REQUIREMENTS basic ini)
|
||||
list(APPEND ADD_INCLUDE "include")
|
||||
|
||||
# sophgo-middleware
|
||||
set(middleware_src_path "${CMAKE_CURRENT_SOURCE_DIR}/../3rd_party/sophgo-middleware/sophgo-middleware")
|
||||
|
||||
|
||||
set(source_dir "src")
|
||||
append_srcs_dir(ADD_SRCS "${source_dir}")
|
||||
|
||||
# middleware
|
||||
set(middleware_include_dir .
|
||||
${middleware_src_path}/v2/component/panel/${CONFIG_SOPHGO_MIDDLEWARE_CHIP}
|
||||
${middleware_src_path}/v2/include
|
||||
${middleware_src_path}/v2/include/isp/${CONFIG_SOPHGO_MIDDLEWARE_CHIP}
|
||||
${middleware_src_path}/v2/sample/common
|
||||
${middleware_src_path}/v2/uapi
|
||||
${middleware_src_path}/v2/3rdparty/inih
|
||||
${middleware_src_path}/v2/modules/ive/include/
|
||||
)
|
||||
list(APPEND ADD_INCLUDE ${middleware_include_dir})
|
||||
set_property(SOURCE ${middleware_include_dir} PROPERTY GENERATED 1)
|
||||
|
||||
append_srcs_dir(middleware_src_dir ${middleware_src_path}/v2/sample/common
|
||||
${middleware_src_path}/v2/component/isp/sensor/sg200x/lontium_lt6911
|
||||
)
|
||||
list(APPEND ADD_SRCS ${middleware_src_dir}
|
||||
"${source_dir}/kvm_mmf.cpp")
|
||||
set_property(SOURCE ${middleware_src_dir} PROPERTY GENERATED 1)
|
||||
|
||||
set(middleware_static_lib_file "")
|
||||
list(APPEND ADD_STATIC_LIB ${middleware_static_lib_file})
|
||||
set_property(SOURCE ${middleware_static_lib_file} PROPERTY GENERATED 1)
|
||||
|
||||
set(mmf_lib_dir ${middleware_src_path}/v2/lib)
|
||||
|
||||
if(CONFIG_SOPHGO_MIDDLEWARE_C_LIBRARY STREQUAL "musl")
|
||||
set(middleware_dynamic_lib_file ${mmf_lib_dir}/libcvi_bin.so
|
||||
${mmf_lib_dir}/libae.so
|
||||
${mmf_lib_dir}/libaf.so
|
||||
${mmf_lib_dir}/libawb.so
|
||||
${mmf_lib_dir}/libcvi_bin_isp.so
|
||||
${mmf_lib_dir}/libisp_algo.so
|
||||
${mmf_lib_dir}/libisp.so
|
||||
${mmf_lib_dir}/libsys.so
|
||||
${mmf_lib_dir}/libvdec.so
|
||||
${mmf_lib_dir}/libvenc.so
|
||||
${mmf_lib_dir}/libvpu.so
|
||||
${mmf_lib_dir}/3rd/libini.so)
|
||||
elseif(CONFIG_SOPHGO_MIDDLEWARE_C_LIBRARY STREQUAL "glibc")
|
||||
set(middleware_dynamic_lib_file ${mmf_lib_dir}/libcvi_vb.so
|
||||
${mmf_lib_dir}/libcvi_sys.so
|
||||
${mmf_lib_dir}/libcvi_debug.so
|
||||
)
|
||||
else()
|
||||
message(FATAL_ERROR "No sophgo middleware c library support, please check menuoconfig to select c library")
|
||||
if(COMMAND register_component)
|
||||
if(NOT EXISTS "$ENV{KVM_MMF_PREFIX}/lib/libkvm_mmf.so")
|
||||
message(FATAL_ERROR "Build standalone kvm_mmf first and set KVM_MMF_PREFIX (support/sg2002/build does this)")
|
||||
endif()
|
||||
list(APPEND ADD_INCLUDE "$ENV{KVM_MMF_PREFIX}/include")
|
||||
file(GLOB video_libraries "$ENV{KVM_MMF_PREFIX}/lib/*.so")
|
||||
list(APPEND ADD_DYNAMIC_LIB ${video_libraries})
|
||||
register_component()
|
||||
return()
|
||||
endif()
|
||||
list(APPEND ADD_DYNAMIC_LIB ${middleware_dynamic_lib_file})
|
||||
set_property(SOURCE ${middleware_dynamic_lib_file} PROPERTY GENERATED 1)
|
||||
|
||||
list(APPEND ADD_DEFINITIONS_PRIVATE -DSENSOR_LONTIUM_LT6911
|
||||
-DSENSOR2_TYPE=LONTIUM_LT6911_2M_60FPS_8BIT)
|
||||
project(kvm_mmf VERSION 1.0 LANGUAGES C CXX)
|
||||
include(GNUInstallDirs)
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
set(SOPHGO_MIDDLEWARE_DIR "" CACHE PATH "CVI SDK middleware root containing v2/")
|
||||
set(SOPHGO_RUNTIME_DIR "" CACHE PATH "Firmware-matched SDK .so directory; defaults to middleware v2/lib")
|
||||
set(SOPHGO_CHIP cv181x CACHE STRING "CVI SDK chip family")
|
||||
option(KVM_MMF_INSTALL_SDK_LIBS "Install the SDK runtime libraries alongside kvm_mmf" ON)
|
||||
if(NOT EXISTS "${SOPHGO_MIDDLEWARE_DIR}/v2/include/cvi_venc.h")
|
||||
message(FATAL_ERROR "Set SOPHGO_MIDDLEWARE_DIR to the CVI SDK middleware root containing v2/include/cvi_venc.h")
|
||||
endif()
|
||||
get_filename_component(middleware "${SOPHGO_MIDDLEWARE_DIR}/v2" REALPATH)
|
||||
if(SOPHGO_RUNTIME_DIR)
|
||||
get_filename_component(runtime "${SOPHGO_RUNTIME_DIR}" REALPATH)
|
||||
else()
|
||||
set(runtime "${middleware}/lib")
|
||||
endif()
|
||||
set(common "${middleware}/sample/common")
|
||||
set(sensor "${middleware}/component/isp/sensor/sg200x/lontium_lt6911")
|
||||
|
||||
register_component(DYNAMIC)
|
||||
add_library(kvm_mmf SHARED
|
||||
src/kvm_mmf.cpp
|
||||
"${common}/sample_common_sys.c"
|
||||
"${common}/sample_common_vi.c"
|
||||
"${common}/sample_common_isp.c"
|
||||
"${common}/sample_common_bin.c"
|
||||
"${common}/sample_common_platform.c"
|
||||
"${common}/sample_common_sensor.c"
|
||||
"${sensor}/lt6911_cmos.c" "${sensor}/lt6911_sensor_ctl.c"
|
||||
)
|
||||
target_compile_features(kvm_mmf PRIVATE cxx_std_17)
|
||||
target_compile_definitions(kvm_mmf PRIVATE SENSOR_LONTIUM_LT6911 SENSOR2_TYPE=LONTIUM_LT6911_2M_60FPS_8BIT)
|
||||
target_compile_options(kvm_mmf PRIVATE -Wall -ffunction-sections -fdata-sections)
|
||||
target_include_directories(kvm_mmf PUBLIC
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
|
||||
)
|
||||
target_include_directories(kvm_mmf PRIVATE
|
||||
"${common}" "${middleware}/include" "${middleware}/uapi"
|
||||
"${middleware}/include/isp/${SOPHGO_CHIP}"
|
||||
"${middleware}/component/panel/${SOPHGO_CHIP}"
|
||||
"${middleware}/3rdparty/inih"
|
||||
)
|
||||
foreach(name cvi_bin ae af awb cvi_bin_isp isp isp_algo sys venc vpu ini)
|
||||
unset(sdk_${name} CACHE)
|
||||
find_library(sdk_${name} NAMES ${name} PATHS "${runtime}" "${runtime}/3rd" NO_DEFAULT_PATH REQUIRED)
|
||||
target_link_libraries(kvm_mmf PRIVATE "${sdk_${name}}")
|
||||
if(KVM_MMF_INSTALL_SDK_LIBS AND sdk_${name} MATCHES "\\.so$")
|
||||
install(FILES "${sdk_${name}}" DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
||||
endif()
|
||||
endforeach()
|
||||
target_link_libraries(kvm_mmf PRIVATE Threads::Threads m atomic)
|
||||
target_link_options(kvm_mmf PRIVATE -Wl,--as-needed -Wl,--gc-sections -Wl,-z,defs -Wl,--no-allow-shlib-undefined)
|
||||
set_target_properties(kvm_mmf PROPERTIES
|
||||
C_VISIBILITY_PRESET hidden CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN YES
|
||||
BUILD_WITH_INSTALL_RPATH YES INSTALL_RPATH "\$ORIGIN")
|
||||
install(TARGETS kvm_mmf LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
||||
install(FILES include/kvm_mmf.hpp DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
set(CMAKE_SYSTEM_NAME Linux)
|
||||
set(CMAKE_SYSTEM_PROCESSOR riscv64)
|
||||
|
||||
set(CMAKE_C_COMPILER riscv64-unknown-linux-musl-gcc)
|
||||
set(CMAKE_CXX_COMPILER riscv64-unknown-linux-musl-g++)
|
||||
set(CMAKE_C_FLAGS_INIT "-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d")
|
||||
set(CMAKE_CXX_FLAGS_INIT "${CMAKE_C_FLAGS_INIT}")
|
||||
@@ -1,71 +1,27 @@
|
||||
#ifndef __KVM_MMF_HPP__
|
||||
#define __KVM_MMF_HPP__
|
||||
#pragma once
|
||||
|
||||
#include "stdint.h"
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct {
|
||||
uint8_t *data[8];
|
||||
int data_size[8];
|
||||
int count;
|
||||
} mmf_stream_t;
|
||||
// VI -> VPSS -> VENC, with no application queue of encoded frames.
|
||||
namespace kvm_mmf __attribute__((visibility("default"))) {
|
||||
|
||||
typedef struct {
|
||||
uint8_t type; // 0, jpg; 1, h265; 2, h264
|
||||
int w;
|
||||
int h;
|
||||
int fmt;
|
||||
uint8_t jpg_quality; // jpeg
|
||||
int gop; // h264
|
||||
int intput_fps; // h264
|
||||
int output_fps; // h264
|
||||
int bitrate; // h264
|
||||
} mmf_venc_cfg_t;
|
||||
enum Codec { MJPEG = 0, H264 = 1 };
|
||||
|
||||
// init sys
|
||||
int mmf_init(void);
|
||||
int mmf_deinit(void);
|
||||
int mmf_try_deinit(bool force);
|
||||
bool mmf_is_init(void);
|
||||
struct Frame {
|
||||
uint8_t *data = nullptr;
|
||||
uint32_t size = 0;
|
||||
uint64_t timestamp = 0; // SDK capture PTS, in microseconds.
|
||||
uint64_t token = 0;
|
||||
bool keyframe = false;
|
||||
};
|
||||
|
||||
// manage vi channels(vi->vpssgroup->vpss->frame)
|
||||
int mmf_get_vi_unused_channel(void);
|
||||
int mmf_vi_init(void);
|
||||
int mmf_vi_deinit(void);
|
||||
int mmf_add_vi_channel_with_enc(int ch, int width, int height, int format);
|
||||
int mmf_add_vi_channel(int ch, int width, int height, int format);
|
||||
int mmf_del_vi_channel(int ch);
|
||||
int mmf_del_vi_channel_all(void);
|
||||
int mmf_reset_vi_channel(int ch, int width, int height, int format);
|
||||
bool mmf_vi_chn_is_open(int ch);
|
||||
int mmf_vi_aligned_width(int ch);
|
||||
void mmf_set_vi_hmirror(int ch, bool en);
|
||||
void mmf_set_vi_vflip(int ch, bool en);
|
||||
void mmf_get_vi_hmirror(int ch, bool *en);
|
||||
void mmf_get_vi_vflip(int ch, bool *en);
|
||||
bool initialize();
|
||||
bool restart();
|
||||
void deinitialize();
|
||||
void stop(Codec codec);
|
||||
|
||||
// get vi frame
|
||||
int mmf_vi_frame_pop(int ch, void **data, int *len, int *width, int *height, int *format);
|
||||
void mmf_vi_frame_free(int ch);
|
||||
// Release the current VI frame immediately. mmf_vi_frame_free() defers
|
||||
// release so the frame can be sent directly to VENC without a second copy.
|
||||
void mmf_vi_frame_release(int ch);
|
||||
int acquire(Codec codec, uint16_t width, uint16_t height, uint16_t quality,
|
||||
uint16_t fps, uint8_t gop, Frame *frame);
|
||||
void release(Codec codec, uint64_t token);
|
||||
|
||||
// invert format
|
||||
int mmf_invert_format_to_maix(int mmf_format);
|
||||
int mmf_invert_format_to_mmf(int maix_format);
|
||||
|
||||
// venc
|
||||
int mmf_enc_jpg_init(int ch, int w, int h, int format, int quality);
|
||||
int mmf_enc_jpg_deinit(int ch);
|
||||
int mmf_enc_jpg_push(int ch, uint8_t *data, int w, int h, int format);
|
||||
int mmf_enc_jpg_push_with_quality(int ch, uint8_t *data, int w, int h, int format, int quality);
|
||||
int mmf_enc_jpg_pop(int ch, uint8_t **data, int *size);
|
||||
int mmf_enc_jpg_free(int ch);
|
||||
int mmf_add_venc_channel(int ch, mmf_venc_cfg_t *cfg);
|
||||
int mmf_del_venc_channel(int ch);
|
||||
int mmf_del_venc_channel_all();
|
||||
int mmf_venc_push(int ch, uint8_t *data, int w, int h, int format);
|
||||
int mmf_venc_pop(int ch, mmf_stream_t *stream);
|
||||
int mmf_venc_free(int ch);
|
||||
|
||||
#endif // __KVM_MMF_HPP__
|
||||
} // namespace kvm_mmf
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ maixcdk_build() {
|
||||
|
||||
DIST_PATH="./dist"
|
||||
rm -rf $DIST_PATH
|
||||
|
||||
|
||||
if [ "$input_value" = "from_zero" ]
|
||||
then
|
||||
maixcdk build <<EOF
|
||||
@@ -24,8 +24,31 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
build_kvm_mmf() {
|
||||
local component="$NanoKVM_PATH/support/sg2002/additional/kvm_mmf"
|
||||
local middleware="${SOPHGO_MIDDLEWARE_DIR:-$MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware}"
|
||||
local runtime="${SOPHGO_RUNTIME_DIR:-$NanoKVM_PATH/server/dl_lib}"
|
||||
local build_dir="$Project_PATH/build/kvm_mmf-build"
|
||||
export KVM_MMF_PREFIX="$Project_PATH/build/kvm_mmf-install"
|
||||
cmake -S "$component" -B "$build_dir" \
|
||||
-DCMAKE_TOOLCHAIN_FILE="$component/cmake/riscv64-musl.cmake" \
|
||||
-DSOPHGO_MIDDLEWARE_DIR="$middleware" -DSOPHGO_RUNTIME_DIR="$runtime" \
|
||||
-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$KVM_MMF_PREFIX" || return 1
|
||||
cmake --build "$build_dir" --parallel || return 1
|
||||
cmake --install "$build_dir" || return 1
|
||||
}
|
||||
|
||||
update_components() {
|
||||
cp -rf "$NanoKVM_PATH/support/sg2002/additional/kvm" "$MAIXCDK_PATH/components/" || return 1
|
||||
cp -rf "$NanoKVM_PATH/support/sg2002/additional/kvm_mmf" "$MAIXCDK_PATH/components/" || return 1
|
||||
cp -f "$NanoKVM_PATH/support/sg2002/additional/peripheral/port/maixcam/maix_i2c.cpp" \
|
||||
"$MAIXCDK_PATH/components/peripheral/port/maixcam/maix_i2c.cpp" || return 1
|
||||
cp -f "$NanoKVM_PATH/support/sg2002/additional/sophgo-middleware/v2/sample/common/sample_common_sensor.c" \
|
||||
"$MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware/v2/sample/common/" || return 1
|
||||
}
|
||||
|
||||
chack_build(){
|
||||
DIST_PATH="./dist"
|
||||
DIST_PATH="./dist"
|
||||
if [ -d "$DIST_PATH" ]
|
||||
then
|
||||
echo "SUCCESS"
|
||||
@@ -41,15 +64,7 @@ maixcdk_clean(){
|
||||
|
||||
case "$1" in
|
||||
update_lib)
|
||||
# This script does not run under "set -e", and a release build relies on
|
||||
# update_lib to guarantee the SDK components match the source tree. A copy
|
||||
# that fails silently here would produce stale libraries, so fail loudly.
|
||||
set -e
|
||||
cp -rf $NanoKVM_PATH/support/sg2002/additional/kvm $MAIXCDK_PATH/components
|
||||
cp -rf $NanoKVM_PATH/support/sg2002/additional/kvm_mmf $MAIXCDK_PATH/components
|
||||
cp -rf $NanoKVM_PATH/support/sg2002/additional/vision $MAIXCDK_PATH/components
|
||||
cp -f $NanoKVM_PATH/support/sg2002/additional/peripheral/port/maixcam/maix_i2c.cpp $MAIXCDK_PATH/components/peripheral/port/maixcam/maix_i2c.cpp
|
||||
cp -f $NanoKVM_PATH/support/sg2002/additional/sophgo-middleware/v2/sample/common/sample_common_sensor.c $MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware/v2/sample/common/
|
||||
update_components || exit 1
|
||||
|
||||
echo -e "\e[36m[build] \e[32mupdate_lib ok!\e[0m"
|
||||
exit 0
|
||||
@@ -106,37 +121,34 @@ then
|
||||
if [ ! -e "$MAIXCDK_PATH/components/kvm" ]
|
||||
then
|
||||
echo -e "\e[36m[build] \e[32mDetected that the library has not been modified; first, move the library\e[0m"
|
||||
|
||||
|
||||
mkdir $NanoKVM_PATH/support/sg2002/additional/original
|
||||
mv -f $MAIXCDK_PATH/components/vision $NanoKVM_PATH/support/sg2002/additional/original
|
||||
# mv $MAIXCDK_PATH/components/maixcam_lib $NanoKVM_PATH/support/sg2002/additional/original
|
||||
mv -f $MAIXCDK_PATH/components/peripheral/port/maixcam/maix_i2c.cpp $NanoKVM_PATH/support/sg2002/additional/original
|
||||
mv -f $MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware/v2/sample/common/sample_common_sensor.c $NanoKVM_PATH/support/sg2002/additional/original
|
||||
|
||||
cp -rf $NanoKVM_PATH/support/sg2002/additional/kvm $MAIXCDK_PATH/components
|
||||
cp -rf $NanoKVM_PATH/support/sg2002/additional/kvm_mmf $MAIXCDK_PATH/components
|
||||
cp -rf $NanoKVM_PATH/support/sg2002/additional/vision $MAIXCDK_PATH/components
|
||||
cp -f $NanoKVM_PATH/support/sg2002/additional/peripheral/port/maixcam/maix_i2c.cpp $MAIXCDK_PATH/components/peripheral/port/maixcam/maix_i2c.cpp
|
||||
cp -f $NanoKVM_PATH/support/sg2002/additional/sophgo-middleware/v2/sample/common/sample_common_sensor.c $MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware/v2/sample/common/
|
||||
|
||||
echo -e "\e[36m[build] \e[32mMove the library completed!\e[0m"
|
||||
fi
|
||||
|
||||
cd $Project_PATH
|
||||
if [ -f $JSON_PATH ]
|
||||
update_components || exit 1
|
||||
|
||||
cd "$Project_PATH"
|
||||
if [ -f "$JSON_PATH" ] && ! grep -q "$Platform_Char" "$JSON_PATH"; then
|
||||
maixcdk_clean || exit 1
|
||||
fi
|
||||
|
||||
if [ "$Project_Name" = "kvm_vision" ]; then
|
||||
build_kvm_mmf || exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$JSON_PATH" ]
|
||||
then
|
||||
if grep -q $Platform_Char $JSON_PATH; then
|
||||
maixcdk_build
|
||||
else
|
||||
maixcdk_clean
|
||||
maixcdk_build "from_zero"
|
||||
fi
|
||||
maixcdk_build
|
||||
else
|
||||
maixcdk_build "from_zero"
|
||||
fi
|
||||
|
||||
RES=$(chack_build)
|
||||
|
||||
|
||||
if [ "$RES" = "SUCCESS" ]
|
||||
then
|
||||
echo -e "\e[36m[build] \e[32mBuild completed! Use \e[34m./build $Project_Name add_to_kvmapp\e[32m to add it to kvmapp.\e[0m"
|
||||
@@ -173,7 +185,7 @@ then
|
||||
else
|
||||
echo -e "\e[36m[build] \e[32mkvm_vision has been moved to kvmapp.\e[0m"
|
||||
fi
|
||||
|
||||
|
||||
else
|
||||
echo -e "\e[36m[build] \e[31mThe source file does not exist. Please compile using \e[34m./build kvm_system \e[31mfirst.\e[0m"
|
||||
exit 1
|
||||
|
||||
@@ -10,14 +10,14 @@ using namespace maix::sys;
|
||||
extern kvm_sys_state_t kvm_sys_state;
|
||||
extern kvm_oled_state_t kvm_oled_state;
|
||||
|
||||
static void write_hdmi_state(uint8_t active)
|
||||
static bool write_hdmi_state(uint8_t active)
|
||||
{
|
||||
char temp_path[] = "/kvmapp/kvm/.state.init.XXXXXX";
|
||||
const char *state = active != 0 ? "1\n" : "0\n";
|
||||
int fd = mkstemp(temp_path);
|
||||
if(fd < 0){
|
||||
printf("failed to create HDMI state file: %d\n", errno);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
int failure = 0;
|
||||
@@ -40,6 +40,7 @@ static void write_hdmi_state(uint8_t active)
|
||||
unlink(temp_path);
|
||||
printf("failed to publish HDMI state file: %d\n", failure);
|
||||
}
|
||||
return failure == 0;
|
||||
}
|
||||
|
||||
uint8_t get_hdmi_version()
|
||||
@@ -88,38 +89,45 @@ void Production_testing_patch(void)
|
||||
system("sync");
|
||||
}
|
||||
|
||||
void new_app_init(void)
|
||||
static bool init_command(const char *command)
|
||||
{
|
||||
if(system(command) == 0) return true;
|
||||
fprintf(stderr, "[kvms] migration failed: %s\n", command);
|
||||
return false;
|
||||
}
|
||||
|
||||
AppInitResult new_app_init(void)
|
||||
{
|
||||
// Update the necessary scripts
|
||||
system("rm -f /boot/logo.jpeg");
|
||||
system("cp -f /kvmapp/system/update-nanokvm.py /etc/kvm/");
|
||||
if(!init_command("mkdir -p /etc/kvm /kvmapp/kvm")) return APP_INIT_FAILED;
|
||||
if(!init_command("cp -f /kvmapp/system/update-nanokvm.py /etc/kvm/")) return APP_INIT_FAILED;
|
||||
system("rm -f /etc/init.d/S02udisk");
|
||||
system("cp -f /kvmapp/system/init.d/S00kmod /etc/init.d/");
|
||||
system("cp -f /kvmapp/system/init.d/S01fs /etc/init.d/");
|
||||
system("cp -f /kvmapp/system/init.d/S03usbdev /etc/init.d/");
|
||||
system("cp -f /kvmapp/system/init.d/S15kvmhwd /etc/init.d/");
|
||||
system("cp -f /kvmapp/system/init.d/S30eth /etc/init.d/");
|
||||
system("cp -f /kvmapp/system/init.d/S50sshd /etc/init.d/");
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S00kmod /etc/init.d/")) return APP_INIT_FAILED;
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S01fs /etc/init.d/")) return APP_INIT_FAILED;
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S03usbdev /etc/init.d/")) return APP_INIT_FAILED;
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S15kvmhwd /etc/init.d/")) return APP_INIT_FAILED;
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S30eth /etc/init.d/")) return APP_INIT_FAILED;
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S50sshd /etc/init.d/")) return APP_INIT_FAILED;
|
||||
if(kvm_wifi_exist()) {
|
||||
system("cp -f /kvmapp/system/init.d/S30wifi /etc/init.d/");
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S30wifi /etc/init.d/")) return APP_INIT_FAILED;
|
||||
} else {
|
||||
system("rm -f /etc/init.d/S30wifi");
|
||||
}
|
||||
|
||||
// if exit /etc/init.d/S98tailscaled then cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/
|
||||
if(access("/etc/init.d/S98tailscaled", F_OK) == 0){
|
||||
system("cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/");
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/")) return APP_INIT_FAILED;
|
||||
}
|
||||
|
||||
// rmmod soph_saradc
|
||||
system("rmmod soph_saradc");
|
||||
system("rm -f /mnt/system/ko/soph_saradc.ko");
|
||||
|
||||
// PCIe Patch
|
||||
// system("cp /kvmapp/system/init.d/S95nanokvm /etc/init.d/");
|
||||
if(access("/kvmapp/jpg_stream/dl_lib/libmaixcam_lib.so", F_OK) != 0){
|
||||
system("cp -f /kvmapp/system/init.d/S95nanokvm /etc/init.d/");
|
||||
}
|
||||
// Keep an already running legacy shell on its original script inode.
|
||||
if(!init_command("cp /kvmapp/system/init.d/S95nanokvm /etc/init.d/.S95nanokvm.new && "
|
||||
"chmod 755 /etc/init.d/.S95nanokvm.new && "
|
||||
"mv -f /etc/init.d/.S95nanokvm.new /etc/init.d/S95nanokvm")) return APP_INIT_FAILED;
|
||||
|
||||
// Remove unnecessary components to speed up boot time
|
||||
system("rm -f /etc/init.d/S04backlight");
|
||||
@@ -129,91 +137,45 @@ void new_app_init(void)
|
||||
system("rm -f /etc/init.d/S99*");
|
||||
|
||||
// Add necessary configuration files for program execution
|
||||
system("mkdir /kvmapp/kvm");
|
||||
system("mkdir /etc/kvm");
|
||||
system("echo 0 > /kvmapp/kvm/now_fps");
|
||||
system("echo 30 > /kvmapp/kvm/fps");
|
||||
system("echo 2000 > /kvmapp/kvm/qlty");
|
||||
system("echo 720 > /kvmapp/kvm/res");
|
||||
system("echo h264 > /kvmapp/kvm/type");
|
||||
write_hdmi_state(0);
|
||||
system("touch /etc/kvm/frame_detact");
|
||||
if(!init_command("echo 0 > /kvmapp/kvm/now_fps && echo 30 > /kvmapp/kvm/fps && "
|
||||
"echo 2000 > /kvmapp/kvm/qlty && echo h264 > /kvmapp/kvm/type && "
|
||||
"touch /etc/kvm/frame_detact")) return APP_INIT_FAILED;
|
||||
if(!write_hdmi_state(0)) return APP_INIT_FAILED;
|
||||
|
||||
// rm jpg_stream & kvm_stream
|
||||
system("rm -rf /kvmapp/jpg_stream");
|
||||
system("rm -f /kvmapp/kvm_system/kvm_stream"); // Cannot delete temporarily; the old production test script uses this file to determine if the download is complete
|
||||
|
||||
system("rm -f /kvmapp/kvm_new_app");
|
||||
system("sync");
|
||||
// system("/etc/init.d/S95nanokvm restart");
|
||||
|
||||
// update ko
|
||||
FILE *fp;
|
||||
uint8_t RW_Data_0[30];
|
||||
uint8_t RW_Data_1[30];
|
||||
fp = popen("md5sum /mnt/system/ko/soph_mipi_rx.ko | grep 086ed01749188975afaa40fb569374f8 | awk '{print $2}'", "r");
|
||||
if ( NULL == fp )
|
||||
{
|
||||
pclose(fp);
|
||||
// return;
|
||||
}
|
||||
fgets((char*)RW_Data_0, 10, fp);
|
||||
pclose(fp);
|
||||
fp = popen("md5sum /mnt/system/ko/soph_mipi_rx.ko | grep 69be7eeded3777f750480a5dd5a1aa26 | awk '{print $2}'", "r");
|
||||
if ( NULL == fp )
|
||||
{
|
||||
pclose(fp);
|
||||
// return;
|
||||
}
|
||||
fgets((char*)RW_Data_1, 10, fp);
|
||||
pclose(fp);
|
||||
FILE *fp = popen("md5sum /mnt/system/ko/soph_mipi_rx.ko", "r");
|
||||
if(!fp) return APP_INIT_FAILED;
|
||||
char digest[33] = {};
|
||||
bool digest_read = fscanf(fp, "%32s", digest) == 1;
|
||||
int digest_status = pclose(fp);
|
||||
if(!digest_read || digest_status != 0) return APP_INIT_FAILED;
|
||||
|
||||
int8_t hdmi_ver = -1;
|
||||
|
||||
if(access("/etc/kvm/hdmi_version", F_OK) == 0){
|
||||
uint8_t RW_Data[2];
|
||||
FILE *fp = fopen("/etc/kvm/hdmi_version", "r");
|
||||
fread(RW_Data, sizeof(char), 2, fp);
|
||||
if(!fp) return APP_INIT_FAILED;
|
||||
int version = fgetc(fp);
|
||||
fclose(fp);
|
||||
if(RW_Data[0] == 'c') hdmi_ver = 1;
|
||||
else if(RW_Data[0] == 'u') hdmi_ver = 2;
|
||||
else if(RW_Data[0] == 'd') hdmi_ver = 2;
|
||||
if(version == EOF) return APP_INIT_FAILED;
|
||||
if(version == 'c') hdmi_ver = 1;
|
||||
else if(version == 'u' || version == 'd') hdmi_ver = 2;
|
||||
}
|
||||
|
||||
// system("/etc/init.d/S03usbdev stop_start");
|
||||
create_temp_watchdog();
|
||||
|
||||
if(hdmi_ver == 2){
|
||||
if(RW_Data_1[0] != '/'){
|
||||
system("cp /kvmapp/system/ko/soph_mipi_rx.ko /mnt/system/ko/soph_mipi_rx.ko");
|
||||
system("sync");
|
||||
system("reboot");
|
||||
} else {
|
||||
system("sync");
|
||||
system("/etc/init.d/S15kvmhwd start");
|
||||
system("/etc/init.d/S95nanokvm restart");
|
||||
}
|
||||
} else {
|
||||
if((RW_Data_0[0] != '/') && (RW_Data_1[0] != '/')){
|
||||
system("cp /kvmapp/system/ko/soph_mipi_rx.ko /mnt/system/ko/soph_mipi_rx.ko");
|
||||
system("sync");
|
||||
system("reboot");
|
||||
} else {
|
||||
system("sync");
|
||||
system("/etc/init.d/S15kvmhwd start");
|
||||
system("/etc/init.d/S95nanokvm restart");
|
||||
}
|
||||
bool current_driver = strcmp(digest, "69be7eeded3777f750480a5dd5a1aa26") == 0 ||
|
||||
(hdmi_ver != 2 && strcmp(digest, "086ed01749188975afaa40fb569374f8") == 0);
|
||||
if(!current_driver){
|
||||
if(!init_command("cp /kvmapp/system/ko/soph_mipi_rx.ko /mnt/system/ko/soph_mipi_rx.ko")) return APP_INIT_FAILED;
|
||||
}
|
||||
|
||||
if(access("/root/old/kvm_new_img", F_OK) == 0){
|
||||
system("rm -r /root/old");
|
||||
}
|
||||
|
||||
system("sync");
|
||||
system("killall NanoKVM-Server");
|
||||
system("rm -r /tmp/server");
|
||||
system("cp -r /kvmapp/server /tmp/");
|
||||
system("/tmp/server/NanoKVM-Server &");
|
||||
if(unlink("/kvmapp/kvm_new_app") != 0) return APP_INIT_FAILED;
|
||||
sync();
|
||||
return current_driver ? APP_INIT_OK : APP_INIT_REBOOT;
|
||||
}
|
||||
|
||||
void build_complete_resolv(void)
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
#include "config.h"
|
||||
|
||||
void new_img_init(void);
|
||||
void new_app_init(void);
|
||||
enum AppInitResult { APP_INIT_OK = 0, APP_INIT_FAILED = 1, APP_INIT_REBOOT = 2 };
|
||||
AppInitResult new_app_init(void);
|
||||
void Production_testing_patch(void);
|
||||
|
||||
#endif // SYSTEM_INIT_H_
|
||||
|
||||
@@ -192,10 +192,15 @@ int main(int argc, char* argv[])
|
||||
new_img_init();
|
||||
}
|
||||
|
||||
// Execute only when it's a new app
|
||||
const bool migrate_only = argc == 2 && strcmp(argv[1], "--migrate") == 0;
|
||||
if(access("/kvmapp/kvm_new_app", F_OK) == 0){
|
||||
new_app_init();
|
||||
AppInitResult result = new_app_init();
|
||||
if(migrate_only || result == APP_INIT_FAILED) return result;
|
||||
// A legacy init script starts us without --migrate and can still be
|
||||
// copying the server. Boot once into the newly installed init script.
|
||||
return system("reboot") == 0 ? 0 : 1;
|
||||
}
|
||||
if(migrate_only) return APP_INIT_OK;
|
||||
|
||||
// only for production testing
|
||||
if (access("/tmp/S49kvmtest", F_OK) == 0){
|
||||
|
||||
@@ -1,61 +1,23 @@
|
||||
#include "kvm_vision.h"
|
||||
#include "maix_basic.hpp"
|
||||
|
||||
using namespace maix;
|
||||
using namespace maix::sys;
|
||||
|
||||
// #define NOT_GET_IMG
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
uint64_t __attribute__((unused)) start_time;
|
||||
// Catch SIGINT signal(e.g. Ctrl + C), and set exit flag to true.
|
||||
signal(SIGINT, [](int sig){ app::set_exit_flag(true);
|
||||
log::info("========================\n");
|
||||
});
|
||||
|
||||
kvmv_hdmi_control(0);
|
||||
kvmv_hdmi_control(1);
|
||||
|
||||
kvmv_init(0);
|
||||
set_h264_gop(30);
|
||||
|
||||
uint16_t get_fream_count = 0;
|
||||
|
||||
while(!app::need_exit()){
|
||||
#ifdef NOT_GET_IMG
|
||||
printf("NOT_GET_IMG ...\n");
|
||||
time::sleep_ms(1000);
|
||||
#else
|
||||
uint8_t* p_kvmv_img_data;
|
||||
uint32_t kvmv_img_data_size;
|
||||
int ret;
|
||||
|
||||
printf("KVM-Vison Get Fream ...\n");
|
||||
|
||||
start_time = time::time_ms();
|
||||
if(get_fream_count < 1){
|
||||
get_fream_count ++;
|
||||
ret = kvmv_read_img(1920, 1080, 1, 3000, &p_kvmv_img_data, &kvmv_img_data_size);
|
||||
} else if(get_fream_count >= 1 && get_fream_count < 2){
|
||||
get_fream_count ++;
|
||||
ret = kvmv_read_img(0, 0, 0, 60, &p_kvmv_img_data, &kvmv_img_data_size);
|
||||
} else {
|
||||
get_fream_count = 0;
|
||||
}
|
||||
|
||||
// printf("kvmv_read_img(): %d \r\n", (int)(time::time_ms() - start_time));
|
||||
|
||||
// ret = kvmv_read_img(1920, 1080, 1, 3000, &p_kvmv_img_data, &kvmv_img_data_size);
|
||||
// ret = kvmv_read_img(1920, 1080, 0, 60, &p_kvmv_img_data, &kvmv_img_data_size);
|
||||
|
||||
printf("kvmv_read_img ret = %d\n", ret);
|
||||
|
||||
// send...
|
||||
// if(ret >= 0){
|
||||
free_kvmv_data(&p_kvmv_img_data);
|
||||
// }
|
||||
#endif
|
||||
}
|
||||
kvmv_deinit();
|
||||
}
|
||||
#include "kvm_vision.h"
|
||||
#include "maix_basic.hpp"
|
||||
|
||||
using namespace maix;
|
||||
|
||||
int main()
|
||||
{
|
||||
signal(SIGINT, [](int) { app::set_exit_flag(true); });
|
||||
kvmv_hdmi_control(1);
|
||||
kvmv_init(0);
|
||||
set_h264_gop(30);
|
||||
|
||||
uint8_t codec = 0;
|
||||
while (!app::need_exit()) {
|
||||
kvmv_frame_t frame = {};
|
||||
int result = kvmv_read_img(1920, 1080, codec, codec ? 3000 : 60, 60, &frame);
|
||||
printf("codec=%u result=%d size=%u pts=%llu\n", codec, result, frame.size,
|
||||
static_cast<unsigned long long>(frame.timestamp));
|
||||
kvmv_release_frame(&frame);
|
||||
codec ^= 1;
|
||||
}
|
||||
kvmv_deinit();
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface MediaStateInfo {
|
||||
open: string[];
|
||||
// True while the attached medium is served as a CD-ROM.
|
||||
cdrom: boolean;
|
||||
// False while /data is mounted read-only: every write would be refused, so
|
||||
// the controls that write are disabled instead of failing on the server.
|
||||
localWritable: boolean;
|
||||
}
|
||||
|
||||
export interface MediaFileEntry {
|
||||
@@ -77,6 +80,9 @@ export const codeNotBrowsable = -13;
|
||||
export const codeInsufficientSpace = -14;
|
||||
// A CD-ROM was asked for a medium that is not an ISO 9660 image.
|
||||
export const codeNotOptical = -15;
|
||||
// The target name is already taken; the UI words this itself instead of
|
||||
// showing the server's English message.
|
||||
export const codeAlreadyExists = -16;
|
||||
|
||||
export function getMedia() {
|
||||
return http.get('/api/storage/media');
|
||||
@@ -107,7 +113,15 @@ export function ejectMedia(force = false) {
|
||||
return http.post('/api/storage/media/eject', { force });
|
||||
}
|
||||
|
||||
export function listMediaFiles(params: { type: MediaType; rel: string; path: string }) {
|
||||
export function listMediaFiles(params: {
|
||||
type: MediaType;
|
||||
rel: string;
|
||||
path: string;
|
||||
// Every directory the file manager is showing, comma separated: the server's
|
||||
// watcher reads all of them, so a change in an expanded folder is noticed
|
||||
// too. Omitted when the caller does not need change notifications.
|
||||
watch?: string;
|
||||
}) {
|
||||
return http.get('/api/storage/media/files', params);
|
||||
}
|
||||
|
||||
@@ -115,6 +129,18 @@ export function deleteMediaFile(data: { type: MediaType; rel: string; path: stri
|
||||
return http.post('/api/storage/media/files/delete', data);
|
||||
}
|
||||
|
||||
// Creates one directory inside a medium. The path is the whole directory, so
|
||||
// the caller builds it the way the file browser builds a child path. existsOk
|
||||
// makes "already there" a success, which an upload of a whole tree needs.
|
||||
export function mkdirMediaFile(data: {
|
||||
type: MediaType;
|
||||
rel: string;
|
||||
path: string;
|
||||
existsOk?: boolean;
|
||||
}) {
|
||||
return http.post('/api/storage/media/files/mkdir', data);
|
||||
}
|
||||
|
||||
// enable/disable the emulated USB disk (Settings - Device). Without `enable`
|
||||
// the server toggles, which is only what a switch wants.
|
||||
export function setVirtualDevice(device: 'network' | 'disk', enable?: boolean) {
|
||||
|
||||
@@ -2,6 +2,25 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* A real button that looks like nothing: the storage menu's cards and the
|
||||
mount-mode picker draw their own surface, and a browser-default button would
|
||||
bring its own border, padding and font with it. */
|
||||
@layer components {
|
||||
.nanokvm-button-base {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-align: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
/* CJK punctuation (、。) exists in some Latin fonts that draw it above the
|
||||
baseline, so whichever font stack happens to claim those two characters
|
||||
decides whether a Chinese or Japanese sentence looks aligned. Pin them to a
|
||||
|
||||
@@ -12,6 +12,10 @@ type MenuItemProps = {
|
||||
className?: string;
|
||||
fresh?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
// A caller that opens its own window from inside the popover asks it to
|
||||
// close with this: the global close signal would also collapse the menu bar,
|
||||
// and a picker that hands over to a modal must not take the bar with it.
|
||||
closeRequest?: number;
|
||||
};
|
||||
|
||||
export const MenuItem = ({
|
||||
@@ -20,7 +24,8 @@ export const MenuItem = ({
|
||||
content,
|
||||
className,
|
||||
fresh,
|
||||
onOpenChange
|
||||
onOpenChange,
|
||||
closeRequest = 0
|
||||
}: MenuItemProps) => {
|
||||
const isBigScreen = useMediaQuery({ minWidth: 640 });
|
||||
const setSubmenuOpenCount = useSetAtom(submenuOpenCountAtom);
|
||||
@@ -29,6 +34,7 @@ export const MenuItem = ({
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
const handledCloseSignalRef = useRef(0);
|
||||
const handledCloseRequestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -46,6 +52,18 @@ export const MenuItem = ({
|
||||
onOpenChange?.(false);
|
||||
}, [isPopoverOpen, menuCloseSignal, onOpenChange, setSubmenuOpenCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (closeRequest === 0 || closeRequest === handledCloseRequestRef.current || !isPopoverOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledCloseRequestRef.current = closeRequest;
|
||||
setIsPopoverOpen(false);
|
||||
setIsTooltipOpen(false);
|
||||
setSubmenuOpenCount((count) => Math.max(0, count - 1));
|
||||
onOpenChange?.(false);
|
||||
}, [closeRequest, isPopoverOpen, onOpenChange, setSubmenuOpenCount]);
|
||||
|
||||
function togglePopover(open: boolean) {
|
||||
setIsTooltipOpen(false);
|
||||
setIsPopoverOpen(open);
|
||||
|
||||
26
web/src/components/ui/button-base.tsx
Normal file
26
web/src/components/ui/button-base.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export type ButtonBaseProps = ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
// A button without a look of its own, for the places that draw the whole
|
||||
// surface themselves (the storage menu's cards, the mount-mode picker). It
|
||||
// keeps the semantics of a button - disabled, focus ring, keyboard - which a
|
||||
// div with an onClick loses.
|
||||
export const ButtonBase = forwardRef<HTMLButtonElement, ButtonBaseProps>(
|
||||
({ className, type = 'button', ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={clsx(
|
||||
'nanokvm-button-base cursor-pointer touch-manipulation select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/80 disabled:cursor-not-allowed disabled:opacity-45',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ButtonBase.displayName = 'ButtonBase';
|
||||
@@ -229,6 +229,11 @@ const ca = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Muntatge del disc',
|
||||
share: 'Compartició de fitxers'
|
||||
},
|
||||
close: 'Tanca',
|
||||
title: 'Imatges i compartició de fitxers',
|
||||
empty:
|
||||
"Encara no hi ha imatges. Puja una ISO o una IMG, o copia'n una al disc des de l'ordinador remot.",
|
||||
@@ -301,7 +306,8 @@ const ca = {
|
||||
downloading: "S'està baixant {{name}}",
|
||||
done: '{{name}} està llest',
|
||||
checksumFailed: 'La imatge no coincideix amb la suma de verificació SHA-256.',
|
||||
blocked: "L'ordinador remot fa servir el disc",
|
||||
blocked:
|
||||
"La màquina objectiu està fent servir aquest disc. Expulsa de manera segura la unitat USB virtual a la màquina objectiu i després desactiva l'interruptor Disc virtual a Configuració > Dispositiu.",
|
||||
copyHint:
|
||||
"També podeu copiar una imatge a la partició de dades per SCP, o des de l'ordinador remot."
|
||||
},
|
||||
@@ -323,7 +329,8 @@ const ca = {
|
||||
size: 'Mida',
|
||||
type: 'Tipus',
|
||||
asc: 'Ascendent',
|
||||
desc: 'Descendent'
|
||||
desc: 'Descendent',
|
||||
modified: 'Modificat'
|
||||
},
|
||||
upload: 'Puja un fitxer al suport',
|
||||
deleteFolderTitle: 'Suprimeix la carpeta',
|
||||
@@ -331,10 +338,24 @@ const ca = {
|
||||
'Voleu suprimir la carpeta {{name}} i tot el que conté? Aquesta acció no es pot desfer.',
|
||||
uploading: "S'està pujant…",
|
||||
uploadFailed: 'La pujada ha fallat',
|
||||
exists: '{{name}} ja existeix',
|
||||
download: 'Baixa',
|
||||
delete: 'Elimina',
|
||||
deleteTitle: 'Elimina el fitxer',
|
||||
deleteConfirm: 'Voleu eliminar {{name}} del suport?'
|
||||
deleteConfirm: 'Voleu eliminar {{name}} del suport?',
|
||||
folder: 'Carpeta',
|
||||
itemCount: '{{count}} elements',
|
||||
up: 'Puja un nivell',
|
||||
uploadButton: 'Puja un fitxer',
|
||||
uploadFolder: 'Puja una carpeta',
|
||||
newFolder: 'Carpeta nova',
|
||||
newFolderPlaceholder: 'Nom de la carpeta',
|
||||
create: 'Crea',
|
||||
dropHint: 'Arrossega fitxers aquí per pujar-los a la carpeta actual',
|
||||
view: {
|
||||
list: 'Vista de llista',
|
||||
grid: 'Vista de graella'
|
||||
}
|
||||
},
|
||||
yes: 'Sí',
|
||||
no: 'No'
|
||||
|
||||
@@ -230,6 +230,11 @@ const cz = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Připojení disku',
|
||||
share: 'Sdílení souborů'
|
||||
},
|
||||
close: 'Zavřít',
|
||||
title: 'Obrázky a sdílení souborů',
|
||||
empty:
|
||||
'Zatím žádné obrazy. Nahrajte ISO nebo IMG, nebo zkopírujte obraz na disk ze vzdáleného počítače.',
|
||||
@@ -302,7 +307,8 @@ const cz = {
|
||||
downloading: 'Stahování {{name}}',
|
||||
done: '{{name}} je připraven',
|
||||
checksumFailed: 'Obraz neodpovídá kontrolnímu součtu SHA-256.',
|
||||
blocked: 'Vzdálený počítač disk používá',
|
||||
blocked:
|
||||
'Vzdálený počítač disk používá. Bezpečně vysuňte virtuální USB disk na vzdáleném počítači a potom vypněte přepínač Virtuální disk v Nastavení > Zařízení.',
|
||||
copyHint:
|
||||
'Obraz můžete také zkopírovat do datového oddílu přes SCP nebo ze vzdáleného počítače.'
|
||||
},
|
||||
@@ -325,17 +331,32 @@ const cz = {
|
||||
size: 'Velikost',
|
||||
type: 'Typ',
|
||||
asc: 'Vzestupně',
|
||||
desc: 'Sestupně'
|
||||
desc: 'Sestupně',
|
||||
modified: 'Změněno'
|
||||
},
|
||||
upload: 'Nahrát soubor do média',
|
||||
deleteFolderTitle: 'Smazat složku',
|
||||
deleteFolderConfirm: 'Smazat složku {{name}} a celý její obsah? Tuto akci nelze vrátit.',
|
||||
uploading: 'Nahrávání…',
|
||||
uploadFailed: 'Nahrání se nezdařilo',
|
||||
exists: '{{name}} už existuje',
|
||||
download: 'Stáhnout',
|
||||
delete: 'Smazat',
|
||||
deleteTitle: 'Smazat soubor',
|
||||
deleteConfirm: 'Smazat {{name}} z média?'
|
||||
deleteConfirm: 'Smazat {{name}} z média?',
|
||||
folder: 'Složka',
|
||||
itemCount: 'Počet položek: {{count}}',
|
||||
up: 'O úroveň výš',
|
||||
uploadButton: 'Nahrát',
|
||||
uploadFolder: 'Nahrát složku',
|
||||
newFolder: 'Nová složka',
|
||||
newFolderPlaceholder: 'Název složky',
|
||||
create: 'Vytvořit',
|
||||
dropHint: 'Přetažením sem nahrajete soubory do této složky',
|
||||
view: {
|
||||
list: 'Seznam',
|
||||
grid: 'Zobrazení ikon'
|
||||
}
|
||||
},
|
||||
yes: 'Ano',
|
||||
no: 'Ne'
|
||||
|
||||
@@ -229,6 +229,11 @@ const da = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Diskmontering',
|
||||
share: 'Fildeling'
|
||||
},
|
||||
close: 'Luk',
|
||||
title: 'Diskbilleder og fildeling',
|
||||
empty:
|
||||
'Der er endnu ingen diskbilleder. Upload en ISO eller IMG, eller kopier en over på disken fra målcomputeren.',
|
||||
@@ -301,7 +306,8 @@ const da = {
|
||||
downloading: 'Downloader {{name}}',
|
||||
done: '{{name}} er klar',
|
||||
checksumFailed: 'Billedet stemmer ikke overens med SHA-256-kontrolsummen.',
|
||||
blocked: 'Målcomputeren bruger disken',
|
||||
blocked:
|
||||
'Målcomputeren bruger disken. Skub det virtuelle USB-drev sikkert ud på målcomputeren, og slå derefter kontakten Virtuel disk fra under Indstillinger > Enhed.',
|
||||
copyHint:
|
||||
'Du kan også kopiere et diskbillede til datapartitionen via SCP eller fra målcomputeren.'
|
||||
},
|
||||
@@ -324,17 +330,32 @@ const da = {
|
||||
size: 'Størrelse',
|
||||
type: 'Type',
|
||||
asc: 'Stigende',
|
||||
desc: 'Faldende'
|
||||
desc: 'Faldende',
|
||||
modified: 'Ændret'
|
||||
},
|
||||
upload: 'Upload en fil til mediet',
|
||||
deleteFolderTitle: 'Slet mappe',
|
||||
deleteFolderConfirm: 'Slet mappen {{name}} og alt dens indhold? Dette kan ikke fortrydes.',
|
||||
uploading: 'Uploader…',
|
||||
uploadFailed: 'Upload mislykkedes',
|
||||
exists: '{{name}} findes allerede',
|
||||
download: 'Download',
|
||||
delete: 'Slet',
|
||||
deleteTitle: 'Slet fil',
|
||||
deleteConfirm: 'Vil du slette {{name}} fra mediet?'
|
||||
deleteConfirm: 'Vil du slette {{name}} fra mediet?',
|
||||
folder: 'Mappe',
|
||||
itemCount: 'Antal elementer: {{count}}',
|
||||
up: 'Gå til den overordnede mappe',
|
||||
uploadButton: 'Upload filer',
|
||||
uploadFolder: 'Upload mappe',
|
||||
newFolder: 'Ny mappe',
|
||||
newFolderPlaceholder: 'Mappenavn',
|
||||
create: 'Opret',
|
||||
dropHint: 'Slip filer her for at uploade dem til denne mappe',
|
||||
view: {
|
||||
list: 'Listevisning',
|
||||
grid: 'Ikonvisning'
|
||||
}
|
||||
},
|
||||
yes: 'Ja',
|
||||
no: 'Nej'
|
||||
|
||||
@@ -235,6 +235,11 @@ const de = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Laufwerk einbinden',
|
||||
share: 'Dateifreigabe'
|
||||
},
|
||||
close: 'Schliessen',
|
||||
title: 'Abbilder & Dateifreigabe',
|
||||
empty:
|
||||
'Noch keine Abbilder. Laden Sie eine ISO oder IMG hoch oder kopieren Sie eine vom Zielrechner auf die Festplatte.',
|
||||
@@ -308,7 +313,8 @@ const de = {
|
||||
downloading: '{{name}} wird heruntergeladen',
|
||||
done: '{{name}} ist bereit',
|
||||
checksumFailed: 'Das Image stimmt nicht mit der SHA-256-Prüfsumme überein.',
|
||||
blocked: 'Der Zielrechner verwendet die Festplatte',
|
||||
blocked:
|
||||
'Der Zielrechner verwendet die Festplatte. Werfen Sie das virtuelle USB-Laufwerk auf dem Zielrechner sicher aus und schalten Sie anschließend den Schalter „Virtueller Datenträger“ unter Einstellungen > Gerät aus.',
|
||||
copyHint:
|
||||
'Sie können ein Abbild auch per SCP auf die Datenpartition kopieren oder vom Zielrechner aus.'
|
||||
},
|
||||
@@ -332,7 +338,8 @@ const de = {
|
||||
size: 'Größe',
|
||||
type: 'Typ',
|
||||
asc: 'Aufsteigend',
|
||||
desc: 'Absteigend'
|
||||
desc: 'Absteigend',
|
||||
modified: 'Geändert'
|
||||
},
|
||||
upload: 'Eine Datei auf das Medium hochladen',
|
||||
deleteFolderTitle: 'Ordner löschen',
|
||||
@@ -340,10 +347,24 @@ const de = {
|
||||
'Den Ordner {{name}} mit seinem gesamten Inhalt löschen? Dies kann nicht rückgängig gemacht werden.',
|
||||
uploading: 'Wird hochgeladen…',
|
||||
uploadFailed: 'Hochladen fehlgeschlagen',
|
||||
exists: '{{name}} existiert bereits',
|
||||
download: 'Herunterladen',
|
||||
delete: 'Löschen',
|
||||
deleteTitle: 'Datei löschen',
|
||||
deleteConfirm: '{{name}} vom Medium löschen?'
|
||||
deleteConfirm: '{{name}} vom Medium löschen?',
|
||||
folder: 'Ordner',
|
||||
itemCount: 'Elemente: {{count}}',
|
||||
up: 'Zum übergeordneten Ordner',
|
||||
uploadButton: 'Hochladen',
|
||||
uploadFolder: 'Ordner hochladen',
|
||||
newFolder: 'Neuer Ordner',
|
||||
newFolderPlaceholder: 'Ordnername',
|
||||
create: 'Erstellen',
|
||||
dropHint: 'Dateien hier ablegen, um sie in diesen Ordner hochzuladen',
|
||||
view: {
|
||||
list: 'Listenansicht',
|
||||
grid: 'Symbolansicht'
|
||||
}
|
||||
},
|
||||
yes: 'Ja',
|
||||
no: 'Nein'
|
||||
|
||||
@@ -228,6 +228,11 @@ const en = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Disk mount',
|
||||
share: 'File sharing'
|
||||
},
|
||||
close: 'Close',
|
||||
title: 'Images & File sharing',
|
||||
empty:
|
||||
'No images yet. Upload an ISO or IMG, or copy one onto the disk from the target machine.',
|
||||
@@ -300,7 +305,8 @@ const en = {
|
||||
downloading: 'Downloading {{name}}',
|
||||
done: '{{name}} is ready',
|
||||
checksumFailed: 'The image does not match the SHA-256 checksum.',
|
||||
blocked: 'The target machine is using the disk',
|
||||
blocked:
|
||||
'The target machine is using the disk. Safely eject the virtual USB drive on the target machine, then turn off the Virtual Disk switch in Settings > Device.',
|
||||
copyHint:
|
||||
'You can also copy an image into the data partition over SCP, or from the target machine.'
|
||||
},
|
||||
@@ -322,7 +328,8 @@ const en = {
|
||||
size: 'Size',
|
||||
type: 'Type',
|
||||
asc: 'Ascending',
|
||||
desc: 'Descending'
|
||||
desc: 'Descending',
|
||||
modified: 'Modified'
|
||||
},
|
||||
upload: 'Upload a file into the medium',
|
||||
deleteFolderTitle: 'Delete folder',
|
||||
@@ -330,10 +337,24 @@ const en = {
|
||||
'Delete the folder {{name}} and everything inside it? This cannot be undone.',
|
||||
uploading: 'Uploading…',
|
||||
uploadFailed: 'Upload failed',
|
||||
exists: '{{name}} already exists',
|
||||
download: 'Download',
|
||||
delete: 'Delete',
|
||||
deleteTitle: 'Delete file',
|
||||
deleteConfirm: 'Delete {{name}} from the medium?'
|
||||
deleteConfirm: 'Delete {{name}} from the medium?',
|
||||
folder: 'Folder',
|
||||
itemCount: 'Items: {{count}}',
|
||||
up: 'Up to the parent folder',
|
||||
uploadButton: 'Upload',
|
||||
uploadFolder: 'Upload folder',
|
||||
newFolder: 'New folder',
|
||||
newFolderPlaceholder: 'Folder name',
|
||||
create: 'Create',
|
||||
dropHint: 'Drop files here to upload them into this folder',
|
||||
view: {
|
||||
list: 'List view',
|
||||
grid: 'Icon view'
|
||||
}
|
||||
},
|
||||
yes: 'Yes',
|
||||
no: 'No'
|
||||
|
||||
@@ -231,6 +231,11 @@ const es = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Montar disco',
|
||||
share: 'Compartir archivos'
|
||||
},
|
||||
close: 'Cerrar',
|
||||
title: 'Imágenes y uso compartido de archivos',
|
||||
empty:
|
||||
'Aún no hay imágenes. Sube una ISO o IMG, o copia una en el disco desde el equipo remoto.',
|
||||
@@ -303,7 +308,8 @@ const es = {
|
||||
downloading: 'Descargando {{name}}',
|
||||
done: '{{name}} está listo',
|
||||
checksumFailed: 'La imagen no coincide con la suma de comprobación SHA-256.',
|
||||
blocked: 'El equipo remoto está usando el disco',
|
||||
blocked:
|
||||
'El equipo remoto está usando el disco. Expulsa de forma segura la unidad USB virtual en el equipo remoto y después apaga el interruptor Disco virtual en Ajustes > Dispositivo.',
|
||||
copyHint:
|
||||
'También puedes copiar una imagen a la partición de datos por SCP, o desde el equipo remoto.'
|
||||
},
|
||||
@@ -326,7 +332,8 @@ const es = {
|
||||
size: 'Tamaño',
|
||||
type: 'Tipo',
|
||||
asc: 'Ascendente',
|
||||
desc: 'Descendente'
|
||||
desc: 'Descendente',
|
||||
modified: 'Modificado'
|
||||
},
|
||||
upload: 'Subir un archivo al medio',
|
||||
deleteFolderTitle: 'Eliminar carpeta',
|
||||
@@ -334,10 +341,24 @@ const es = {
|
||||
'¿Eliminar la carpeta {{name}} y todo su contenido? Esta acción no se puede deshacer.',
|
||||
uploading: 'Subiendo…',
|
||||
uploadFailed: 'La subida ha fallado',
|
||||
exists: '{{name}} ya existe',
|
||||
download: 'Descargar',
|
||||
delete: 'Eliminar',
|
||||
deleteTitle: 'Eliminar el archivo',
|
||||
deleteConfirm: '¿Eliminar {{name}} del medio?'
|
||||
deleteConfirm: '¿Eliminar {{name}} del medio?',
|
||||
folder: 'Carpeta',
|
||||
itemCount: 'Elementos: {{count}}',
|
||||
up: 'Ir a la carpeta superior',
|
||||
uploadButton: 'Subir',
|
||||
uploadFolder: 'Subir carpeta',
|
||||
newFolder: 'Nueva carpeta',
|
||||
newFolderPlaceholder: 'Nombre de la carpeta',
|
||||
create: 'Crear',
|
||||
dropHint: 'Suelta archivos aquí para subirlos a esta carpeta',
|
||||
view: {
|
||||
list: 'Vista de lista',
|
||||
grid: 'Vista de iconos'
|
||||
}
|
||||
},
|
||||
yes: 'Sí',
|
||||
no: 'No'
|
||||
|
||||
@@ -234,6 +234,11 @@ const fr = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Montage du disque',
|
||||
share: 'Partage de fichiers'
|
||||
},
|
||||
close: 'Fermer',
|
||||
title: 'Images et partage de fichiers',
|
||||
empty:
|
||||
"Aucune image pour l'instant. Envoyez une ISO ou une IMG, ou copiez-en une sur le disque depuis la machine distante.",
|
||||
@@ -306,7 +311,8 @@ const fr = {
|
||||
downloading: 'Téléchargement de {{name}}',
|
||||
done: '{{name}} est prêt',
|
||||
checksumFailed: "L'image ne correspond pas à la somme de contrôle SHA-256.",
|
||||
blocked: 'La machine distante utilise le disque',
|
||||
blocked:
|
||||
'La machine distante utilise le disque. Éjectez en toute sécurité le lecteur USB virtuel sur cette machine, puis désactivez le commutateur Disque virtuel dans Paramètres > Appareil.',
|
||||
copyHint:
|
||||
'Vous pouvez aussi copier une image dans la partition de données via SCP, ou depuis la machine distante.'
|
||||
},
|
||||
@@ -330,7 +336,8 @@ const fr = {
|
||||
size: 'Taille',
|
||||
type: 'Type',
|
||||
asc: 'Croissant',
|
||||
desc: 'Décroissant'
|
||||
desc: 'Décroissant',
|
||||
modified: 'Modifié'
|
||||
},
|
||||
upload: 'Envoyer un fichier sur le support',
|
||||
deleteFolderTitle: 'Supprimer le dossier',
|
||||
@@ -338,10 +345,24 @@ const fr = {
|
||||
'Supprimer le dossier {{name}} et tout son contenu ? Cette action est irréversible.',
|
||||
uploading: 'Envoi…',
|
||||
uploadFailed: "Échec de l'envoi",
|
||||
exists: '{{name}} existe déjà',
|
||||
download: 'Télécharger',
|
||||
delete: 'Supprimer',
|
||||
deleteTitle: 'Supprimer le fichier',
|
||||
deleteConfirm: 'Supprimer {{name}} du support ?'
|
||||
deleteConfirm: 'Supprimer {{name}} du support ?',
|
||||
folder: 'Dossier',
|
||||
itemCount: 'Éléments : {{count}}',
|
||||
up: 'Accéder au dossier parent',
|
||||
uploadButton: 'Importer',
|
||||
uploadFolder: 'Téléverser un dossier',
|
||||
newFolder: 'Nouveau dossier',
|
||||
newFolderPlaceholder: 'Nom du dossier',
|
||||
create: 'Créer',
|
||||
dropHint: 'Déposez des fichiers ici pour les importer dans ce dossier',
|
||||
view: {
|
||||
list: 'Vue en liste',
|
||||
grid: 'Vue en icônes'
|
||||
}
|
||||
},
|
||||
yes: 'Oui',
|
||||
no: 'Non'
|
||||
|
||||
@@ -232,6 +232,11 @@ const hu = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Lemez csatlakoztatása',
|
||||
share: 'Fájlmegosztás'
|
||||
},
|
||||
close: 'Bezárás',
|
||||
title: 'Képek és fájlmegosztás',
|
||||
empty:
|
||||
'Még nincsenek képek. Töltsön fel egy ISO vagy IMG fájlt, vagy másoljon egyet a lemezre a célgépről.',
|
||||
@@ -305,7 +310,8 @@ const hu = {
|
||||
downloading: '{{name}} letöltése',
|
||||
done: '{{name}} elkészült',
|
||||
checksumFailed: 'A lemezkép nem egyezik a SHA-256 ellenőrzőösszeggel.',
|
||||
blocked: 'A célgép használja a lemezt',
|
||||
blocked:
|
||||
'A célgép használja a lemezt. Biztonságosan válassza le a virtuális USB-meghajtót a célgépen, majd kapcsolja ki a Virtuális lemez kapcsolót a Beállítások > Eszköz menüben.',
|
||||
copyHint:
|
||||
'A képet SCP-vel is a data partícióra másolhatja, vagy a célgépről is átmásolhatja.'
|
||||
},
|
||||
@@ -328,7 +334,8 @@ const hu = {
|
||||
size: 'Méret',
|
||||
type: 'Típus',
|
||||
asc: 'Növekvő',
|
||||
desc: 'Csökkenő'
|
||||
desc: 'Csökkenő',
|
||||
modified: 'Módosítva'
|
||||
},
|
||||
upload: 'Fájl feltöltése az adathordozóra',
|
||||
deleteFolderTitle: 'Mappa törlése',
|
||||
@@ -336,10 +343,24 @@ const hu = {
|
||||
'Törli a(z) {{name}} mappát és a teljes tartalmát? Ez nem vonható vissza.',
|
||||
uploading: 'Feltöltés…',
|
||||
uploadFailed: 'A feltöltés nem sikerült',
|
||||
exists: '{{name}} már létezik',
|
||||
download: 'Letöltés',
|
||||
delete: 'Törlés',
|
||||
deleteTitle: 'Fájl törlése',
|
||||
deleteConfirm: 'Törli a következőt: {{name}} az adathordozóról?'
|
||||
deleteConfirm: 'Törli a következőt: {{name}} az adathordozóról?',
|
||||
folder: 'Mappa',
|
||||
itemCount: 'Elemek száma: {{count}}',
|
||||
up: 'Ugrás a szülőmappába',
|
||||
uploadButton: 'Feltöltés',
|
||||
uploadFolder: 'Mappa feltöltése',
|
||||
newFolder: 'Új mappa',
|
||||
newFolderPlaceholder: 'Mappa neve',
|
||||
create: 'Létrehozás',
|
||||
dropHint: 'Húzza ide a fájlokat a mappába való feltöltéshez',
|
||||
view: {
|
||||
list: 'Listanézet',
|
||||
grid: 'Ikonnézet'
|
||||
}
|
||||
},
|
||||
yes: 'Igen',
|
||||
no: 'Nem'
|
||||
|
||||
@@ -230,6 +230,11 @@ const id = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Pasang disk',
|
||||
share: 'Berbagi berkas'
|
||||
},
|
||||
close: 'Tutup',
|
||||
title: 'Gambar dan berbagi file',
|
||||
empty: 'Belum ada gambar. Unggah ISO atau IMG, atau salin satu ke disk dari komputer target.',
|
||||
notAttached: 'Tidak ada yang terpasang',
|
||||
@@ -301,7 +306,8 @@ const id = {
|
||||
downloading: 'Mengunduh {{name}}',
|
||||
done: '{{name}} siap',
|
||||
checksumFailed: 'Image tidak cocok dengan checksum SHA-256.',
|
||||
blocked: 'Komputer target sedang menggunakan disk',
|
||||
blocked:
|
||||
'Komputer target sedang menggunakan disk. Keluarkan drive USB virtual dengan aman di komputer target, lalu matikan tombol Disk Virtual di Pengaturan > Perangkat.',
|
||||
copyHint:
|
||||
'Anda juga dapat menyalin citra ke partisi data lewat SCP, atau dari komputer target.'
|
||||
},
|
||||
@@ -324,7 +330,8 @@ const id = {
|
||||
size: 'Ukuran',
|
||||
type: 'Jenis',
|
||||
asc: 'Naik',
|
||||
desc: 'Turun'
|
||||
desc: 'Turun',
|
||||
modified: 'Diubah'
|
||||
},
|
||||
upload: 'Unggah file ke media',
|
||||
deleteFolderTitle: 'Hapus folder',
|
||||
@@ -332,10 +339,24 @@ const id = {
|
||||
'Hapus folder {{name}} beserta seluruh isinya? Tindakan ini tidak dapat dibatalkan.',
|
||||
uploading: 'Mengunggah…',
|
||||
uploadFailed: 'Unggahan gagal',
|
||||
exists: '{{name}} sudah ada',
|
||||
download: 'Unduh',
|
||||
delete: 'Hapus',
|
||||
deleteTitle: 'Hapus file',
|
||||
deleteConfirm: 'Hapus {{name}} dari media?'
|
||||
deleteConfirm: 'Hapus {{name}} dari media?',
|
||||
folder: 'Folder',
|
||||
itemCount: 'Jumlah item: {{count}}',
|
||||
up: 'Ke folder induk',
|
||||
uploadButton: 'Unggah',
|
||||
uploadFolder: 'Unggah folder',
|
||||
newFolder: 'Folder baru',
|
||||
newFolderPlaceholder: 'Nama folder',
|
||||
create: 'Buat',
|
||||
dropHint: 'Letakkan berkas di sini untuk mengunggahnya ke folder ini',
|
||||
view: {
|
||||
list: 'Tampilan daftar',
|
||||
grid: 'Tampilan ikon'
|
||||
}
|
||||
},
|
||||
yes: 'Ya',
|
||||
no: 'Tidak'
|
||||
|
||||
@@ -232,6 +232,11 @@ const it = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Monta disco',
|
||||
share: 'Condivisione file'
|
||||
},
|
||||
close: 'Chiudi',
|
||||
title: 'Immagini e condivisione file',
|
||||
empty:
|
||||
'Nessuna immagine. Carica un file ISO o IMG oppure copiane uno sul disco dalla macchina remota.',
|
||||
@@ -304,7 +309,8 @@ const it = {
|
||||
downloading: 'Download di {{name}}',
|
||||
done: '{{name}} è pronto',
|
||||
checksumFailed: "L'immagine non corrisponde al checksum SHA-256.",
|
||||
blocked: 'La macchina remota sta usando il disco',
|
||||
blocked:
|
||||
'La macchina remota sta usando il disco. Espelli in modo sicuro l’unità USB virtuale sul computer remoto, poi disattiva l’interruttore Disco virtuale in Impostazioni > Dispositivo.',
|
||||
copyHint:
|
||||
"Puoi anche copiare un'immagine nella partizione dati via SCP, o dalla macchina remota."
|
||||
},
|
||||
@@ -327,7 +333,8 @@ const it = {
|
||||
size: 'Dimensione',
|
||||
type: 'Tipo',
|
||||
asc: 'Crescente',
|
||||
desc: 'Decrescente'
|
||||
desc: 'Decrescente',
|
||||
modified: 'Modificato'
|
||||
},
|
||||
upload: 'Carica un file nel supporto',
|
||||
deleteFolderTitle: 'Elimina cartella',
|
||||
@@ -335,10 +342,24 @@ const it = {
|
||||
"Eliminare la cartella {{name}} e tutto il suo contenuto? L'operazione non può essere annullata.",
|
||||
uploading: 'Caricamento…',
|
||||
uploadFailed: 'Caricamento non riuscito',
|
||||
exists: '{{name}} esiste già',
|
||||
download: 'Scarica',
|
||||
delete: 'Elimina',
|
||||
deleteTitle: 'Elimina file',
|
||||
deleteConfirm: 'Eliminare {{name}} dal supporto?'
|
||||
deleteConfirm: 'Eliminare {{name}} dal supporto?',
|
||||
folder: 'Cartella',
|
||||
itemCount: 'Elementi: {{count}}',
|
||||
up: 'Vai alla cartella superiore',
|
||||
uploadButton: 'Carica',
|
||||
uploadFolder: 'Carica cartella',
|
||||
newFolder: 'Nuova cartella',
|
||||
newFolderPlaceholder: 'Nome della cartella',
|
||||
create: 'Crea',
|
||||
dropHint: 'Trascina qui i file per caricarli in questa cartella',
|
||||
view: {
|
||||
list: 'Vista elenco',
|
||||
grid: 'Vista icone'
|
||||
}
|
||||
},
|
||||
yes: 'Sì',
|
||||
no: 'No'
|
||||
|
||||
@@ -230,6 +230,11 @@ const ja = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'ディスクマウント',
|
||||
share: 'ファイル共有'
|
||||
},
|
||||
close: '閉じる',
|
||||
title: 'イメージとファイル共有',
|
||||
empty:
|
||||
'イメージはまだありません。ISO または IMG をアップロードするか、ターゲットマシンからディスクにコピーしてください。',
|
||||
@@ -302,7 +307,8 @@ const ja = {
|
||||
downloading: '{{name}} をダウンロード中',
|
||||
done: '{{name}} の準備ができました',
|
||||
checksumFailed: 'イメージが SHA-256 チェックサムと一致しません。',
|
||||
blocked: 'ターゲットマシンがディスクを使用しています',
|
||||
blocked:
|
||||
'ターゲットマシンがディスクを使用しています。ターゲットマシンで仮想 USB ドライブを安全に取り出してから、設定 > デバイスで仮想ディスクのスイッチをオフにしてください。',
|
||||
copyHint:
|
||||
'イメージは SCP でデータパーティションにコピーするか、ターゲットマシンから直接コピーすることもできます。'
|
||||
},
|
||||
@@ -325,7 +331,8 @@ const ja = {
|
||||
size: 'サイズ',
|
||||
type: '種類',
|
||||
asc: '昇順',
|
||||
desc: '降順'
|
||||
desc: '降順',
|
||||
modified: '更新日時'
|
||||
},
|
||||
upload: 'メディアにファイルをアップロード',
|
||||
deleteFolderTitle: 'フォルダーを削除',
|
||||
@@ -333,10 +340,24 @@ const ja = {
|
||||
'フォルダー {{name}} とその中身をすべて削除しますか?この操作は元に戻せません。',
|
||||
uploading: 'アップロード中…',
|
||||
uploadFailed: 'アップロードに失敗しました',
|
||||
exists: '{{name}} は既に存在します',
|
||||
download: 'ダウンロード',
|
||||
delete: '削除',
|
||||
deleteTitle: 'ファイルを削除',
|
||||
deleteConfirm: '{{name}} をメディアから削除しますか?'
|
||||
deleteConfirm: '{{name}} をメディアから削除しますか?',
|
||||
folder: 'フォルダー',
|
||||
itemCount: '{{count}} 項目',
|
||||
up: '親フォルダーへ移動',
|
||||
uploadButton: 'アップロード',
|
||||
uploadFolder: 'フォルダーをアップロード',
|
||||
newFolder: '新しいフォルダー',
|
||||
newFolderPlaceholder: 'フォルダー名',
|
||||
create: '作成',
|
||||
dropHint: 'ここにファイルをドロップして、このフォルダーにアップロード',
|
||||
view: {
|
||||
list: 'リスト表示',
|
||||
grid: 'アイコン表示'
|
||||
}
|
||||
},
|
||||
yes: 'はい',
|
||||
no: 'いいえ'
|
||||
|
||||
@@ -225,6 +225,11 @@ const ko = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: '디스크 마운트',
|
||||
share: '파일 공유'
|
||||
},
|
||||
close: '닫기',
|
||||
title: '이미지 및 파일 공유',
|
||||
empty:
|
||||
'아직 이미지가 없습니다. ISO 또는 IMG를 업로드하거나 대상 컴퓨터에서 디스크로 복사하세요.',
|
||||
@@ -296,7 +301,8 @@ const ko = {
|
||||
downloading: '{{name}} 다운로드 중',
|
||||
done: '{{name}} 준비 완료',
|
||||
checksumFailed: '이미지가 SHA-256 체크섬과 일치하지 않습니다.',
|
||||
blocked: '대상 컴퓨터가 디스크를 사용 중입니다',
|
||||
blocked:
|
||||
'대상 컴퓨터가 디스크를 사용 중입니다. 대상 컴퓨터에서 가상 USB 드라이브를 안전하게 꺼낸 다음 설정 > 장치에서 가상 디스크 스위치를 끄세요.',
|
||||
copyHint:
|
||||
'SCP로 데이터 파티션에 이미지를 복사하거나 대상 컴퓨터에서 직접 복사할 수도 있습니다.'
|
||||
},
|
||||
@@ -317,7 +323,8 @@ const ko = {
|
||||
size: '크기',
|
||||
type: '종류',
|
||||
asc: '오름차순',
|
||||
desc: '내림차순'
|
||||
desc: '내림차순',
|
||||
modified: '수정한 날짜'
|
||||
},
|
||||
upload: '미디어에 파일 업로드',
|
||||
deleteFolderTitle: '폴더 삭제',
|
||||
@@ -325,10 +332,24 @@ const ko = {
|
||||
'폴더 {{name}}와(과) 그 안의 모든 내용을 삭제할까요? 되돌릴 수 없습니다.',
|
||||
uploading: '업로드 중…',
|
||||
uploadFailed: '업로드 실패',
|
||||
exists: '{{name}}이(가) 이미 존재합니다',
|
||||
download: '다운로드',
|
||||
delete: '삭제',
|
||||
deleteTitle: '파일 삭제',
|
||||
deleteConfirm: '미디어에서 {{name}}을(를) 삭제할까요?'
|
||||
deleteConfirm: '미디어에서 {{name}}을(를) 삭제할까요?',
|
||||
folder: '폴더',
|
||||
itemCount: '{{count}}개 항목',
|
||||
up: '상위 폴더로 이동',
|
||||
uploadButton: '업로드',
|
||||
uploadFolder: '폴더 업로드',
|
||||
newFolder: '새 폴더',
|
||||
newFolderPlaceholder: '폴더 이름',
|
||||
create: '만들기',
|
||||
dropHint: '여기에 파일을 놓으면 이 폴더에 업로드됩니다',
|
||||
view: {
|
||||
list: '목록 보기',
|
||||
grid: '아이콘 보기'
|
||||
}
|
||||
},
|
||||
yes: '예',
|
||||
no: '아니요'
|
||||
|
||||
@@ -230,6 +230,11 @@ const nb = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Diskmontering',
|
||||
share: 'Fildeling'
|
||||
},
|
||||
close: 'Lukk',
|
||||
title: 'Diskbilder og fildeling',
|
||||
empty:
|
||||
'Ingen bilder ennå. Last opp en ISO eller IMG, eller kopier en over til disken fra målmaskinen.',
|
||||
@@ -302,7 +307,8 @@ const nb = {
|
||||
downloading: 'Laster ned {{name}}',
|
||||
done: '{{name}} er klar',
|
||||
checksumFailed: 'Bildet samsvarer ikke med SHA-256-kontrollsummen.',
|
||||
blocked: 'Målmaskinen bruker disken',
|
||||
blocked:
|
||||
'Målmaskinen bruker disken. Løs ut den virtuelle USB-disken på målmaskinen på en trygg måte, og slå deretter av Virtuell disk-bryteren under Innstillinger > Enhet.',
|
||||
copyHint: 'Du kan også kopiere et bilde til datapartisjonen via SCP, eller fra målmaskinen.'
|
||||
},
|
||||
files: {
|
||||
@@ -324,17 +330,32 @@ const nb = {
|
||||
size: 'Størrelse',
|
||||
type: 'Type',
|
||||
asc: 'Stigende',
|
||||
desc: 'Synkende'
|
||||
desc: 'Synkende',
|
||||
modified: 'Endret'
|
||||
},
|
||||
upload: 'Last opp en fil til mediet',
|
||||
deleteFolderTitle: 'Slett mappe',
|
||||
deleteFolderConfirm: 'Slette mappen {{name}} og alt innholdet? Dette kan ikke angres.',
|
||||
uploading: 'Laster opp…',
|
||||
uploadFailed: 'Opplasting mislyktes',
|
||||
exists: '{{name}} finnes allerede',
|
||||
download: 'Last ned',
|
||||
delete: 'Slett',
|
||||
deleteTitle: 'Slett fil',
|
||||
deleteConfirm: 'Vil du slette {{name}} fra mediet?'
|
||||
deleteConfirm: 'Vil du slette {{name}} fra mediet?',
|
||||
folder: 'Mappe',
|
||||
itemCount: 'Antall elementer: {{count}}',
|
||||
up: 'Gå til overordnet mappe',
|
||||
uploadButton: 'Last opp',
|
||||
uploadFolder: 'Last opp mappe',
|
||||
newFolder: 'Ny mappe',
|
||||
newFolderPlaceholder: 'Mappenavn',
|
||||
create: 'Opprett',
|
||||
dropHint: 'Slipp filer her for å laste dem opp til denne mappen',
|
||||
view: {
|
||||
list: 'Listevisning',
|
||||
grid: 'Ikonvisning'
|
||||
}
|
||||
},
|
||||
yes: 'Ja',
|
||||
no: 'Nei'
|
||||
|
||||
@@ -233,6 +233,11 @@ const nl = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Schijf koppelen',
|
||||
share: 'Bestanden delen'
|
||||
},
|
||||
close: 'Sluiten',
|
||||
title: 'Afbeeldingen en bestandsdeling',
|
||||
empty:
|
||||
'Nog geen afbeeldingen. Upload een ISO of IMG, of kopieer er een naar de schijf vanaf de doelcomputer.',
|
||||
@@ -306,7 +311,8 @@ const nl = {
|
||||
downloading: '{{name}} wordt gedownload',
|
||||
done: '{{name}} is klaar',
|
||||
checksumFailed: 'De image komt niet overeen met de SHA-256-controlesom.',
|
||||
blocked: 'De doelcomputer gebruikt de schijf',
|
||||
blocked:
|
||||
'De doelcomputer gebruikt de schijf. Werp de virtuele USB-schijf veilig uit op de doelcomputer en schakel daarna de schakelaar Virtuele schijf uit via Instellingen > Apparaat.',
|
||||
copyHint:
|
||||
'Je kunt een image ook via SCP naar de datapartitie kopiëren, of vanaf de doelcomputer.'
|
||||
},
|
||||
@@ -329,7 +335,8 @@ const nl = {
|
||||
size: 'Grootte',
|
||||
type: 'Type',
|
||||
asc: 'Oplopend',
|
||||
desc: 'Aflopend'
|
||||
desc: 'Aflopend',
|
||||
modified: 'Gewijzigd'
|
||||
},
|
||||
upload: 'Een bestand naar het medium uploaden',
|
||||
deleteFolderTitle: 'Map verwijderen',
|
||||
@@ -337,10 +344,24 @@ const nl = {
|
||||
'De map {{name}} en alles erin verwijderen? Dit kan niet ongedaan worden gemaakt.',
|
||||
uploading: 'Uploaden…',
|
||||
uploadFailed: 'Uploaden mislukt',
|
||||
exists: '{{name}} bestaat al',
|
||||
download: 'Downloaden',
|
||||
delete: 'Verwijderen',
|
||||
deleteTitle: 'Bestand verwijderen',
|
||||
deleteConfirm: '{{name}} van het medium verwijderen?'
|
||||
deleteConfirm: '{{name}} van het medium verwijderen?',
|
||||
folder: 'Map',
|
||||
itemCount: 'Aantal items: {{count}}',
|
||||
up: 'Naar de bovenliggende map',
|
||||
uploadButton: 'Uploaden',
|
||||
uploadFolder: 'Map uploaden',
|
||||
newFolder: 'Nieuwe map',
|
||||
newFolderPlaceholder: 'Mapnaam',
|
||||
create: 'Aanmaken',
|
||||
dropHint: 'Sleep bestanden hierheen om ze naar deze map te uploaden',
|
||||
view: {
|
||||
list: 'Lijstweergave',
|
||||
grid: 'Pictogramweergave'
|
||||
}
|
||||
},
|
||||
yes: 'Ja',
|
||||
no: 'Nee'
|
||||
|
||||
@@ -231,6 +231,11 @@ const pl = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Montowanie dysku',
|
||||
share: 'Udostępnianie plików'
|
||||
},
|
||||
close: 'Zamknij',
|
||||
title: 'Obrazy i udostępnianie plików',
|
||||
empty:
|
||||
'Nie ma jeszcze obrazów. Prześlij plik ISO lub IMG albo skopiuj go na dysk ze zdalnego komputera.',
|
||||
@@ -302,7 +307,8 @@ const pl = {
|
||||
downloading: 'Pobieranie {{name}}',
|
||||
done: '{{name}} jest gotowy',
|
||||
checksumFailed: 'Obraz nie zgadza się z sumą kontrolną SHA-256.',
|
||||
blocked: 'Zdalny komputer używa dysku',
|
||||
blocked:
|
||||
'Zdalny komputer używa dysku. Bezpiecznie wysuń wirtualny dysk USB na zdalnym komputerze, a następnie wyłącz przełącznik Dysk wirtualny w Ustawienia > Urządzenie.',
|
||||
copyHint:
|
||||
'Obraz możesz też skopiować do partycji danych przez SCP albo ze zdalnego komputera.'
|
||||
},
|
||||
@@ -324,7 +330,8 @@ const pl = {
|
||||
size: 'Rozmiar',
|
||||
type: 'Typ',
|
||||
asc: 'Rosnąco',
|
||||
desc: 'Malejąco'
|
||||
desc: 'Malejąco',
|
||||
modified: 'Zmodyfikowano'
|
||||
},
|
||||
upload: 'Prześlij plik na nośnik',
|
||||
deleteFolderTitle: 'Usuń folder',
|
||||
@@ -332,10 +339,24 @@ const pl = {
|
||||
'Usunąć folder {{name}} wraz z całą zawartością? Tej operacji nie można cofnąć.',
|
||||
uploading: 'Przesyłanie…',
|
||||
uploadFailed: 'Przesyłanie nie powiodło się',
|
||||
exists: '{{name}} już istnieje',
|
||||
download: 'Pobierz',
|
||||
delete: 'Usuń',
|
||||
deleteTitle: 'Usuń plik',
|
||||
deleteConfirm: 'Usunąć {{name}} z nośnika?'
|
||||
deleteConfirm: 'Usunąć {{name}} z nośnika?',
|
||||
folder: 'Folder',
|
||||
itemCount: 'Liczba elementów: {{count}}',
|
||||
up: 'Przejdź do folderu nadrzędnego',
|
||||
uploadButton: 'Prześlij',
|
||||
uploadFolder: 'Prześlij folder',
|
||||
newFolder: 'Nowy folder',
|
||||
newFolderPlaceholder: 'Nazwa folderu',
|
||||
create: 'Utwórz',
|
||||
dropHint: 'Upuść tutaj pliki, aby przesłać je do tego folderu',
|
||||
view: {
|
||||
list: 'Widok listy',
|
||||
grid: 'Widok ikon'
|
||||
}
|
||||
},
|
||||
yes: 'Tak',
|
||||
no: 'Nie'
|
||||
|
||||
@@ -230,6 +230,11 @@ const pt_br = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Montagem do disco',
|
||||
share: 'Compartilhamento de arquivos'
|
||||
},
|
||||
close: 'Fechar',
|
||||
title: 'Imagens e compartilhamento de arquivos',
|
||||
empty:
|
||||
'Ainda não há imagens. Envie uma ISO ou IMG, ou copie uma para o disco a partir da máquina remota.',
|
||||
@@ -302,7 +307,8 @@ const pt_br = {
|
||||
downloading: 'Baixando {{name}}',
|
||||
done: '{{name}} está pronto',
|
||||
checksumFailed: 'A imagem não corresponde ao checksum SHA-256.',
|
||||
blocked: 'A máquina remota está usando o disco',
|
||||
blocked:
|
||||
'A máquina de destino está usando este disco. Ejete com segurança a unidade USB virtual na máquina de destino e depois desative a chave Disco virtual em Configurações > Dispositivo.',
|
||||
copyHint:
|
||||
'Você também pode copiar uma imagem para a partição de dados via SCP, ou a partir da máquina remota.'
|
||||
},
|
||||
@@ -325,7 +331,8 @@ const pt_br = {
|
||||
size: 'Tamanho',
|
||||
type: 'Tipo',
|
||||
asc: 'Crescente',
|
||||
desc: 'Decrescente'
|
||||
desc: 'Decrescente',
|
||||
modified: 'Modificado'
|
||||
},
|
||||
upload: 'Enviar um arquivo para a mídia',
|
||||
deleteFolderTitle: 'Excluir pasta',
|
||||
@@ -333,10 +340,24 @@ const pt_br = {
|
||||
'Excluir a pasta {{name}} e todo o seu conteúdo? Esta ação não pode ser desfeita.',
|
||||
uploading: 'Enviando…',
|
||||
uploadFailed: 'Falha no envio',
|
||||
exists: '{{name}} já existe',
|
||||
download: 'Baixar',
|
||||
delete: 'Excluir',
|
||||
deleteTitle: 'Excluir arquivo',
|
||||
deleteConfirm: 'Excluir {{name}} da mídia?'
|
||||
deleteConfirm: 'Excluir {{name}} da mídia?',
|
||||
folder: 'Pasta',
|
||||
itemCount: '{{count}} itens',
|
||||
up: 'Subir um nível',
|
||||
uploadButton: 'Enviar arquivo',
|
||||
uploadFolder: 'Enviar pasta',
|
||||
newFolder: 'Nova pasta',
|
||||
newFolderPlaceholder: 'Nome da pasta',
|
||||
create: 'Criar',
|
||||
dropHint: 'Arraste arquivos para cá para enviá-los à pasta atual',
|
||||
view: {
|
||||
list: 'Visualização em lista',
|
||||
grid: 'Visualização em grade'
|
||||
}
|
||||
},
|
||||
yes: 'Sim',
|
||||
no: 'Não'
|
||||
|
||||
@@ -230,6 +230,11 @@ const ru = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Монтирование диска',
|
||||
share: 'Общий доступ к файлам'
|
||||
},
|
||||
close: 'Закрыть',
|
||||
title: 'Образы и обмен файлами',
|
||||
empty:
|
||||
'Образов пока нет. Загрузите ISO или IMG либо скопируйте образ на диск с целевого компьютера.',
|
||||
@@ -302,7 +307,8 @@ const ru = {
|
||||
downloading: 'Скачивание {{name}}',
|
||||
done: '{{name}} готов',
|
||||
checksumFailed: 'Образ не совпадает с контрольной суммой SHA-256.',
|
||||
blocked: 'Целевой компьютер использует диск',
|
||||
blocked:
|
||||
'Целевой компьютер использует диск. Безопасно извлеките виртуальный USB-накопитель на целевом компьютере, затем выключите переключатель «Виртуальный диск» в разделе Настройки > Устройство.',
|
||||
copyHint: 'Образ можно также скопировать в раздел данных по SCP или с целевого компьютера.'
|
||||
},
|
||||
files: {
|
||||
@@ -323,7 +329,8 @@ const ru = {
|
||||
size: 'Размер',
|
||||
type: 'Тип',
|
||||
asc: 'По возрастанию',
|
||||
desc: 'По убыванию'
|
||||
desc: 'По убыванию',
|
||||
modified: 'Изменено'
|
||||
},
|
||||
upload: 'Загрузить файл на носитель',
|
||||
deleteFolderTitle: 'Удалить папку',
|
||||
@@ -331,10 +338,24 @@ const ru = {
|
||||
'Удалить папку {{name}} со всем содержимым? Это действие нельзя отменить.',
|
||||
uploading: 'Загрузка…',
|
||||
uploadFailed: 'Не удалось загрузить',
|
||||
exists: '{{name}} уже существует',
|
||||
download: 'Скачать',
|
||||
delete: 'Удалить',
|
||||
deleteTitle: 'Удалить файл',
|
||||
deleteConfirm: 'Удалить {{name}} с носителя?'
|
||||
deleteConfirm: 'Удалить {{name}} с носителя?',
|
||||
folder: 'Папка',
|
||||
itemCount: 'Количество элементов: {{count}}',
|
||||
up: 'Перейти в родительскую папку',
|
||||
uploadButton: 'Загрузить',
|
||||
uploadFolder: 'Загрузить папку',
|
||||
newFolder: 'Новая папка',
|
||||
newFolderPlaceholder: 'Имя папки',
|
||||
create: 'Создать',
|
||||
dropHint: 'Перетащите файлы сюда, чтобы загрузить их в эту папку',
|
||||
view: {
|
||||
list: 'Список',
|
||||
grid: 'Значки'
|
||||
}
|
||||
},
|
||||
yes: 'Да',
|
||||
no: 'Нет'
|
||||
|
||||
@@ -226,6 +226,11 @@ const se = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Diskmontering',
|
||||
share: 'Fildelning'
|
||||
},
|
||||
close: 'Stäng',
|
||||
title: 'Avbildningar och fildelning',
|
||||
empty:
|
||||
'Inga avbildningar än. Ladda upp en ISO eller IMG, eller kopiera en till disken från måldatorn.',
|
||||
@@ -298,7 +303,8 @@ const se = {
|
||||
downloading: 'Laddar ner {{name}}',
|
||||
done: '{{name}} är klar',
|
||||
checksumFailed: 'Avbilden matchar inte SHA-256-kontrollsumman.',
|
||||
blocked: 'Måldatorn använder disken',
|
||||
blocked:
|
||||
'Måldatorn använder disken. Mata ut den virtuella USB-enheten säkert på måldatorn och stäng sedan av reglaget Virtuell disk under Inställningar > Enhet.',
|
||||
copyHint:
|
||||
'Du kan också kopiera en avbildning till datapartitionen via SCP, eller från måldatorn.'
|
||||
},
|
||||
@@ -320,17 +326,32 @@ const se = {
|
||||
size: 'Storlek',
|
||||
type: 'Typ',
|
||||
asc: 'Stigande',
|
||||
desc: 'Fallande'
|
||||
desc: 'Fallande',
|
||||
modified: 'Ändrad'
|
||||
},
|
||||
upload: 'Ladda upp en fil till mediet',
|
||||
deleteFolderTitle: 'Ta bort mapp',
|
||||
deleteFolderConfirm: 'Ta bort mappen {{name}} och allt innehåll? Detta kan inte ångras.',
|
||||
uploading: 'Laddar upp…',
|
||||
uploadFailed: 'Uppladdningen misslyckades',
|
||||
exists: '{{name}} finns redan',
|
||||
download: 'Hämta',
|
||||
delete: 'Ta bort',
|
||||
deleteTitle: 'Ta bort fil',
|
||||
deleteConfirm: 'Ta bort {{name}} från mediet?'
|
||||
deleteConfirm: 'Ta bort {{name}} från mediet?',
|
||||
folder: 'Mapp',
|
||||
itemCount: '{{count}} objekt',
|
||||
up: 'Upp en nivå',
|
||||
uploadButton: 'Ladda upp fil',
|
||||
uploadFolder: 'Ladda upp mapp',
|
||||
newFolder: 'Ny mapp',
|
||||
newFolderPlaceholder: 'Mappnamn',
|
||||
create: 'Skapa',
|
||||
dropHint: 'Dra filer hit för att ladda upp dem till den aktuella mappen',
|
||||
view: {
|
||||
list: 'Listvy',
|
||||
grid: 'Rutnätsvy'
|
||||
}
|
||||
},
|
||||
yes: 'Ja',
|
||||
no: 'Nej'
|
||||
|
||||
@@ -224,6 +224,11 @@ const th = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'เมาต์ดิสก์',
|
||||
share: 'การแชร์ไฟล์'
|
||||
},
|
||||
close: 'ปิด',
|
||||
title: 'อิมเมจและการแชร์ไฟล์',
|
||||
empty: 'ยังไม่มีอิมเมจ อัปโหลด ISO หรือ IMG หรือคัดลอกไฟล์ลงดิสก์จากเครื่องเป้าหมาย',
|
||||
notAttached: 'ยังไม่ได้เมาท์สิ่งใด',
|
||||
@@ -293,7 +298,8 @@ const th = {
|
||||
downloading: 'กำลังดาวน์โหลด {{name}}',
|
||||
done: '{{name}} พร้อมใช้งาน',
|
||||
checksumFailed: 'อิมเมจไม่ตรงกับ checksum SHA-256',
|
||||
blocked: 'เครื่องเป้าหมายกำลังใช้ดิสก์นี้',
|
||||
blocked:
|
||||
'เครื่องเป้าหมายกำลังใช้ดิสก์นี้ โปรดนำไดรฟ์ USB เสมือนออกอย่างปลอดภัยบนเครื่องเป้าหมาย จากนั้นปิดสวิตช์ดิสก์เสมือนใน การตั้งค่า > อุปกรณ์',
|
||||
copyHint: 'คุณสามารถคัดลอกอิมเมจไปยังพาร์ติชันข้อมูลผ่าน SCP หรือจากเครื่องเป้าหมายก็ได้'
|
||||
},
|
||||
files: {
|
||||
@@ -313,7 +319,8 @@ const th = {
|
||||
size: 'ขนาด',
|
||||
type: 'ชนิด',
|
||||
asc: 'น้อยไปมาก',
|
||||
desc: 'มากไปน้อย'
|
||||
desc: 'มากไปน้อย',
|
||||
modified: 'แก้ไขเมื่อ'
|
||||
},
|
||||
upload: 'อัปโหลดไฟล์ลงสื่อ',
|
||||
deleteFolderTitle: 'ลบโฟลเดอร์',
|
||||
@@ -321,10 +328,24 @@ const th = {
|
||||
'ลบโฟลเดอร์ {{name}} และทุกอย่างภายในหรือไม่? การดำเนินการนี้ไม่สามารถย้อนกลับได้',
|
||||
uploading: 'กำลังอัปโหลด…',
|
||||
uploadFailed: 'อัปโหลดล้มเหลว',
|
||||
exists: '{{name}} มีอยู่แล้ว',
|
||||
download: 'ดาวน์โหลด',
|
||||
delete: 'ลบ',
|
||||
deleteTitle: 'ลบไฟล์',
|
||||
deleteConfirm: 'ลบ {{name}} ออกจากสื่อหรือไม่?'
|
||||
deleteConfirm: 'ลบ {{name}} ออกจากสื่อหรือไม่?',
|
||||
folder: 'โฟลเดอร์',
|
||||
itemCount: '{{count}} รายการ',
|
||||
up: 'ไปยังโฟลเดอร์หลัก',
|
||||
uploadButton: 'อัปโหลด',
|
||||
uploadFolder: 'อัปโหลดโฟลเดอร์',
|
||||
newFolder: 'โฟลเดอร์ใหม่',
|
||||
newFolderPlaceholder: 'ชื่อโฟลเดอร์',
|
||||
create: 'สร้าง',
|
||||
dropHint: 'วางไฟล์ที่นี่เพื่ออัปโหลดไปยังโฟลเดอร์นี้',
|
||||
view: {
|
||||
list: 'มุมมองรายการ',
|
||||
grid: 'มุมมองไอคอน'
|
||||
}
|
||||
},
|
||||
yes: 'ใช่',
|
||||
no: 'ไม่ใช่'
|
||||
|
||||
@@ -229,6 +229,11 @@ const tr = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Disk bağlama',
|
||||
share: 'Dosya paylaşımı'
|
||||
},
|
||||
close: 'Kapat',
|
||||
title: 'Disk imajları ve dosya paylaşımı',
|
||||
empty:
|
||||
'Henüz imaj yok. Bir ISO veya IMG yükleyin ya da hedef makineden diske bir tane kopyalayın.',
|
||||
@@ -301,7 +306,8 @@ const tr = {
|
||||
downloading: '{{name}} indiriliyor',
|
||||
done: '{{name}} hazır',
|
||||
checksumFailed: 'İmaj, SHA-256 sağlama toplamıyla eşleşmiyor.',
|
||||
blocked: 'Hedef makine diski kullanıyor',
|
||||
blocked:
|
||||
'Hedef makine bu diski kullanıyor. Hedef makinede sanal USB sürücüsünü güvenli şekilde çıkarın, ardından Ayarlar > Aygıt bölümünden Sanal Disk anahtarını kapatın.',
|
||||
copyHint: 'İmajı SCP ile veri bölümüne ya da hedef makineden doğrudan kopyalayabilirsiniz.'
|
||||
},
|
||||
files: {
|
||||
@@ -322,7 +328,8 @@ const tr = {
|
||||
size: 'Boyut',
|
||||
type: 'Tür',
|
||||
asc: 'Artan',
|
||||
desc: 'Azalan'
|
||||
desc: 'Azalan',
|
||||
modified: 'Değiştirilme'
|
||||
},
|
||||
upload: 'Ortama dosya yükle',
|
||||
deleteFolderTitle: 'Klasörü sil',
|
||||
@@ -330,10 +337,24 @@ const tr = {
|
||||
'{{name}} klasörü ve içindeki her şey silinsin mi? Bu işlem geri alınamaz.',
|
||||
uploading: 'Yükleniyor…',
|
||||
uploadFailed: 'Yükleme başarısız oldu',
|
||||
exists: '{{name}} zaten var',
|
||||
download: 'İndir',
|
||||
delete: 'Sil',
|
||||
deleteTitle: 'Dosyayı sil',
|
||||
deleteConfirm: '{{name}} ortamdan silinsin mi?'
|
||||
deleteConfirm: '{{name}} ortamdan silinsin mi?',
|
||||
folder: 'Klasör',
|
||||
itemCount: '{{count}} öğe',
|
||||
up: 'Bir üst dizine çık',
|
||||
uploadButton: 'Dosya yükle',
|
||||
uploadFolder: 'Klasör yükle',
|
||||
newFolder: 'Yeni klasör',
|
||||
newFolderPlaceholder: 'Klasör adı',
|
||||
create: 'Oluştur',
|
||||
dropHint: 'Dosyaları geçerli klasöre yüklemek için buraya sürükleyin',
|
||||
view: {
|
||||
list: 'Liste görünümü',
|
||||
grid: 'Izgara görünümü'
|
||||
}
|
||||
},
|
||||
yes: 'Evet',
|
||||
no: 'Hayır'
|
||||
|
||||
@@ -231,6 +231,11 @@ const uk = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Монтування диска',
|
||||
share: 'Спільний доступ до файлів'
|
||||
},
|
||||
close: 'Закрити',
|
||||
title: 'Образи та обмін файлами',
|
||||
empty:
|
||||
"Образів ще немає. Завантажте ISO або IMG чи скопіюйте образ на диск із цільового комп'ютера.",
|
||||
@@ -303,7 +308,8 @@ const uk = {
|
||||
downloading: 'Звантаження {{name}}',
|
||||
done: '{{name}} готовий',
|
||||
checksumFailed: 'Образ не збігається з контрольною сумою SHA-256.',
|
||||
blocked: "Цільовий комп'ютер використовує диск",
|
||||
blocked:
|
||||
"Цільовий комп'ютер використовує диск. Безпечно витягніть віртуальний USB-накопичувач на цільовому комп'ютері, потім вимкніть перемикач «Віртуальний диск» у меню Налаштування > Пристрій.",
|
||||
copyHint:
|
||||
'Образ також можна скопіювати в розділ даних через SCP або з цільового комп’ютера.'
|
||||
},
|
||||
@@ -325,17 +331,32 @@ const uk = {
|
||||
size: 'Розмір',
|
||||
type: 'Тип',
|
||||
asc: 'За зростанням',
|
||||
desc: 'За спаданням'
|
||||
desc: 'За спаданням',
|
||||
modified: 'Змінено'
|
||||
},
|
||||
upload: 'Завантажити файл на носій',
|
||||
deleteFolderTitle: 'Видалити теку',
|
||||
deleteFolderConfirm: 'Видалити теку {{name}} з усім вмістом? Цю дію неможливо скасувати.',
|
||||
uploading: 'Завантаження…',
|
||||
uploadFailed: 'Не вдалося завантажити',
|
||||
exists: '{{name}} вже існує',
|
||||
download: 'Звантажити',
|
||||
delete: 'Видалити',
|
||||
deleteTitle: 'Видалити файл',
|
||||
deleteConfirm: 'Видалити {{name}} з носія?'
|
||||
deleteConfirm: 'Видалити {{name}} з носія?',
|
||||
folder: 'Папка',
|
||||
itemCount: 'Кількість елементів: {{count}}',
|
||||
up: 'Перейти до батьківської папки',
|
||||
uploadButton: 'Завантажити',
|
||||
uploadFolder: 'Завантажити папку',
|
||||
newFolder: 'Нова папка',
|
||||
newFolderPlaceholder: 'Назва папки',
|
||||
create: 'Створити',
|
||||
dropHint: 'Перетягніть файли сюди, щоб завантажити їх у цю папку',
|
||||
view: {
|
||||
list: 'Список',
|
||||
grid: 'Піктограми'
|
||||
}
|
||||
},
|
||||
yes: 'Так',
|
||||
no: 'Ні'
|
||||
|
||||
@@ -229,6 +229,11 @@ const vi = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: 'Gắn đĩa',
|
||||
share: 'Chia sẻ tệp'
|
||||
},
|
||||
close: 'Đóng',
|
||||
title: 'Hình ảnh và chia sẻ tệp',
|
||||
empty:
|
||||
'Chưa có hình ảnh nào. Hãy tải lên tệp ISO hoặc IMG, hoặc sao chép một tệp vào đĩa từ máy đích.',
|
||||
@@ -299,7 +304,8 @@ const vi = {
|
||||
downloading: 'Đang tải xuống {{name}}',
|
||||
done: '{{name}} đã sẵn sàng',
|
||||
checksumFailed: 'Ảnh đĩa không khớp với mã kiểm tra SHA-256.',
|
||||
blocked: 'Máy đích đang dùng đĩa',
|
||||
blocked:
|
||||
'Máy đích đang dùng đĩa. Hãy tháo an toàn ổ USB ảo trên máy đích, sau đó tắt công tắc Đĩa ảo trong Cài đặt > Thiết bị.',
|
||||
copyHint: 'Bạn cũng có thể sao chép ảnh vào phân vùng dữ liệu qua SCP, hoặc từ máy đích.'
|
||||
},
|
||||
files: {
|
||||
@@ -320,7 +326,8 @@ const vi = {
|
||||
size: 'Kích thước',
|
||||
type: 'Loại',
|
||||
asc: 'Tăng dần',
|
||||
desc: 'Giảm dần'
|
||||
desc: 'Giảm dần',
|
||||
modified: 'Ngày sửa đổi'
|
||||
},
|
||||
upload: 'Tải một tệp lên phương tiện',
|
||||
deleteFolderTitle: 'Xóa thư mục',
|
||||
@@ -328,10 +335,24 @@ const vi = {
|
||||
'Xóa thư mục {{name}} và toàn bộ nội dung bên trong? Không thể hoàn tác thao tác này.',
|
||||
uploading: 'Đang tải lên…',
|
||||
uploadFailed: 'Tải lên không thành công',
|
||||
exists: '{{name}} đã tồn tại',
|
||||
download: 'Tải xuống',
|
||||
delete: 'Xóa',
|
||||
deleteTitle: 'Xóa tệp',
|
||||
deleteConfirm: 'Xóa {{name}} khỏi phương tiện?'
|
||||
deleteConfirm: 'Xóa {{name}} khỏi phương tiện?',
|
||||
folder: 'Thư mục',
|
||||
itemCount: 'Số mục: {{count}}',
|
||||
up: 'Đi đến thư mục cha',
|
||||
uploadButton: 'Tải lên',
|
||||
uploadFolder: 'Tải thư mục lên',
|
||||
newFolder: 'Thư mục mới',
|
||||
newFolderPlaceholder: 'Tên thư mục',
|
||||
create: 'Tạo',
|
||||
dropHint: 'Thả tệp vào đây để tải lên thư mục này',
|
||||
view: {
|
||||
list: 'Dạng danh sách',
|
||||
grid: 'Dạng biểu tượng'
|
||||
}
|
||||
},
|
||||
yes: 'Có',
|
||||
no: 'Không'
|
||||
|
||||
@@ -220,6 +220,11 @@ const zh = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: '磁盘挂载',
|
||||
share: '文件共享'
|
||||
},
|
||||
close: '关闭',
|
||||
title: '镜像挂载/文件共享',
|
||||
empty: '还没有镜像。可以上传 ISO/IMG,也可以让被控机拷到这张盘上。',
|
||||
notAttached: '未装载任何介质',
|
||||
@@ -289,7 +294,8 @@ const zh = {
|
||||
downloading: '正在下载 {{name}}',
|
||||
done: '{{name}} 已就绪',
|
||||
checksumFailed: '镜像与 SHA-256 校验值不符。',
|
||||
blocked: '被控机正在使用这张盘',
|
||||
blocked:
|
||||
'被控机正在使用这张盘。请先在被控机上安全弹出虚拟 U 盘,再到设置 > 设备关闭虚拟 U 盘开关。',
|
||||
copyHint: '也可以通过 SCP 把镜像放进数据分区,或在被控机上直接拷贝。'
|
||||
},
|
||||
files: {
|
||||
@@ -308,17 +314,32 @@ const zh = {
|
||||
size: '大小',
|
||||
type: '类型',
|
||||
asc: '升序',
|
||||
desc: '降序'
|
||||
desc: '降序',
|
||||
modified: '修改时间'
|
||||
},
|
||||
upload: '上传文件到介质',
|
||||
deleteFolderTitle: '删除文件夹',
|
||||
deleteFolderConfirm: '删除文件夹 {{name}} 及其中的全部内容?此操作无法撤销。',
|
||||
uploading: '正在上传…',
|
||||
uploadFailed: '上传失败',
|
||||
exists: '{{name}} 已存在',
|
||||
download: '下载',
|
||||
delete: '删除',
|
||||
deleteTitle: '删除文件',
|
||||
deleteConfirm: '确定从介质中删除 {{name}}?'
|
||||
deleteConfirm: '确定从介质中删除 {{name}}?',
|
||||
folder: '文件夹',
|
||||
itemCount: '{{count}} 项',
|
||||
up: '返回上一级',
|
||||
uploadButton: '上传文件',
|
||||
uploadFolder: '上传文件夹',
|
||||
newFolder: '新建文件夹',
|
||||
newFolderPlaceholder: '文件夹名称',
|
||||
create: '创建',
|
||||
dropHint: '拖拽文件到此处即可上传到当前目录',
|
||||
view: {
|
||||
list: '列表视图',
|
||||
grid: '图标视图'
|
||||
}
|
||||
},
|
||||
yes: '确定',
|
||||
no: '取消'
|
||||
|
||||
@@ -220,6 +220,11 @@ const zh_tw = {
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
cards: {
|
||||
mount: '磁碟掛載',
|
||||
share: '檔案分享'
|
||||
},
|
||||
close: '關閉',
|
||||
title: '映像檔掛載 / 檔案共享',
|
||||
empty: '還沒有映像檔。可以上傳 ISO/IMG,也可以讓受控裝置拷到這張磁碟上。',
|
||||
notAttached: '尚未裝載任何媒體',
|
||||
@@ -289,7 +294,8 @@ const zh_tw = {
|
||||
downloading: '正在下載 {{name}}',
|
||||
done: '{{name}} 已就緒',
|
||||
checksumFailed: '映像檔與 SHA-256 校驗值不符。',
|
||||
blocked: '受控裝置正在使用這張磁碟',
|
||||
blocked:
|
||||
'受控裝置正在使用這張磁碟。請先在受控裝置上安全退出虛擬隨身碟,再到設定 > 設備關閉虛擬隨身碟開關。',
|
||||
copyHint: '也可以透過 SCP 把映像檔放進資料分割區,或在受控裝置上直接複製。'
|
||||
},
|
||||
files: {
|
||||
@@ -308,17 +314,32 @@ const zh_tw = {
|
||||
size: '大小',
|
||||
type: '類型',
|
||||
asc: '升冪',
|
||||
desc: '降冪'
|
||||
desc: '降冪',
|
||||
modified: '修改時間'
|
||||
},
|
||||
upload: '上傳檔案到媒體',
|
||||
deleteFolderTitle: '刪除資料夾',
|
||||
deleteFolderConfirm: '刪除資料夾 {{name}} 及其中的所有內容?此操作無法復原。',
|
||||
uploading: '正在上傳…',
|
||||
uploadFailed: '上傳失敗',
|
||||
exists: '{{name}} 已存在',
|
||||
download: '下載',
|
||||
delete: '刪除',
|
||||
deleteTitle: '刪除檔案',
|
||||
deleteConfirm: '確定從媒體中刪除 {{name}}?'
|
||||
deleteConfirm: '確定從媒體中刪除 {{name}}?',
|
||||
folder: '資料夾',
|
||||
itemCount: '{{count}} 個項目',
|
||||
up: '回到上一層',
|
||||
uploadButton: '上傳檔案',
|
||||
uploadFolder: '上傳資料夾',
|
||||
newFolder: '新增資料夾',
|
||||
newFolderPlaceholder: '資料夾名稱',
|
||||
create: '建立',
|
||||
dropHint: '拖曳檔案到此處即可上傳到目前資料夾',
|
||||
view: {
|
||||
list: '清單檢視',
|
||||
grid: '圖示檢視'
|
||||
}
|
||||
},
|
||||
yes: '確定',
|
||||
no: '取消'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
import { ControlRegionMode, InputRegion, Resolution } from '@/types';
|
||||
import { ControlRegionMode, InputRegion } from '@/types';
|
||||
|
||||
export const isHdmiEnabledAtom = atom(true);
|
||||
|
||||
@@ -12,9 +12,6 @@ export const videoModeAtom = atom('');
|
||||
|
||||
export const videoScaleAtom = atom<number>(1.0);
|
||||
|
||||
// browser screen resolution
|
||||
export const resolutionAtom = atom<Resolution | null>(null);
|
||||
|
||||
// currently effective absolute mouse input region
|
||||
export const inputRegionAtom = atom<InputRegion | null>(null);
|
||||
export const manualInputRegionAtom = atom<InputRegion | null>(null);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Resolution } from '@/types';
|
||||
|
||||
const LANGUAGE_KEY = 'nano-kvm-language';
|
||||
const VIDEO_MODE_KEY = 'nano-kvm-vide-mode';
|
||||
const VIDEO_SCALE_KEY = 'nano-kvm-video-scale';
|
||||
const WEB_RESOLUTION_KEY = 'nano-kvm-web-resolution';
|
||||
const FPS_KEY = 'nano-kvm-fps';
|
||||
const QUALITY_KEY = 'nano-kvm-quality';
|
||||
const GOP_KEY = 'nano-kvm-gop';
|
||||
@@ -81,20 +78,6 @@ export function setVideoScale(scale: number): void {
|
||||
localStorage.setItem(VIDEO_SCALE_KEY, String(scale));
|
||||
}
|
||||
|
||||
export function getResolution(): Resolution | null {
|
||||
const resolution = localStorage.getItem(WEB_RESOLUTION_KEY);
|
||||
if (resolution) {
|
||||
const obj = JSON.parse(window.atob(resolution));
|
||||
return obj as Resolution;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setResolution(resolution: Resolution) {
|
||||
localStorage.setItem(WEB_RESOLUTION_KEY, window.btoa(JSON.stringify(resolution)));
|
||||
}
|
||||
|
||||
export function getFps() {
|
||||
const fps = localStorage.getItem(FPS_KEY);
|
||||
return fps ? Number(fps) : null;
|
||||
|
||||
@@ -18,14 +18,22 @@ import { extension } from '@/lib/file-names.ts';
|
||||
// shared folder is deliberately not a disk, because it is a directory this
|
||||
// device exchanges with the target machine while an .img is a disk image the
|
||||
// emulated USB disk carries, and the two used to share one icon.
|
||||
export function MediaIcon({ item, className }: { item: MediaItem; className?: string }) {
|
||||
export function MediaIcon({
|
||||
item,
|
||||
size = 14,
|
||||
className
|
||||
}: {
|
||||
item: MediaItem;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
if (item.type === 'partition') {
|
||||
return <FolderSyncIcon size={14} className={className} />;
|
||||
return <FolderSyncIcon size={size} className={className} />;
|
||||
}
|
||||
if (isOpticalImage(item)) {
|
||||
return <DiscIcon size={14} className={className} />;
|
||||
return <DiscIcon size={size} className={className} />;
|
||||
}
|
||||
return <HardDriveIcon size={14} className={className} />;
|
||||
return <HardDriveIcon size={size} className={className} />;
|
||||
}
|
||||
|
||||
// The shared folder carries whatever the two machines exchange, and a listing
|
||||
@@ -122,7 +130,17 @@ const audioExtensions = new Set(['aac', 'flac', 'm4a', 'mp3', 'oga', 'ogg', 'opu
|
||||
|
||||
// FileTypeIcon draws one entry of a listing, at the size and colour its rows
|
||||
// use. Directories are not its business: the caller draws the folder glyph.
|
||||
export function FileTypeIcon({ name }: { name: string }) {
|
||||
// The grid view draws the same glyphs much larger, so both the size and the
|
||||
// colour can be handed in.
|
||||
export function FileTypeIcon({
|
||||
name,
|
||||
size = 14,
|
||||
className
|
||||
}: {
|
||||
name: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const ext = extension(name);
|
||||
const Icon = archiveExtensions.has(ext)
|
||||
? FileArchiveIcon
|
||||
@@ -136,5 +154,5 @@ export function FileTypeIcon({ name }: { name: string }) {
|
||||
? FileTextIcon
|
||||
: FileIcon;
|
||||
|
||||
return <Icon size={14} className="shrink-0 text-neutral-500" />;
|
||||
return <Icon size={size} className={className ?? 'shrink-0 text-neutral-500'} />;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
inputRegionAtom,
|
||||
manualInputRegionAtom,
|
||||
manualRegionsAtom,
|
||||
resolutionAtom,
|
||||
selectedManualRegionAtom,
|
||||
selectedOriginalResolutionAtom,
|
||||
videoModeAtom
|
||||
@@ -65,7 +64,6 @@ const Console = () => {
|
||||
const captureStatus = useCaptureStatus(activeVideoMode);
|
||||
|
||||
const [videoMode, setVideoMode] = useAtom(videoModeAtom);
|
||||
const [resolution, setResolution] = useAtom(resolutionAtom);
|
||||
const [inputRegion, setInputRegion] = useAtom(inputRegionAtom);
|
||||
const [controlRegionMode, setControlRegionModeState] = useAtom(controlRegionModeAtom);
|
||||
const setManualInputRegion = useSetAtom(manualInputRegionAtom);
|
||||
@@ -94,8 +92,6 @@ const Console = () => {
|
||||
|
||||
setVideoMode(activeVideoMode);
|
||||
|
||||
const res = storage.getResolution() || { width: 0, height: 0 };
|
||||
setResolution(res);
|
||||
setInputRegion(null);
|
||||
setManualInputRegion(null);
|
||||
setManualRegions([]);
|
||||
@@ -141,7 +137,6 @@ const Console = () => {
|
||||
setInputRegion,
|
||||
setManualInputRegion,
|
||||
setManualRegions,
|
||||
setResolution,
|
||||
setSelectedManualRegion,
|
||||
setSelectedOriginalResolution,
|
||||
setVideoMode
|
||||
@@ -172,7 +167,7 @@ const Console = () => {
|
||||
clearTimeout(validationTimer);
|
||||
}
|
||||
validationTimer = setTimeout(() => {
|
||||
const mediaSize = getMediaSize(target, resolution);
|
||||
const mediaSize = getMediaSize(target);
|
||||
if (!mediaSize || !isMediaReady(target) || isInputRegionCompatible(region, mediaSize)) {
|
||||
return;
|
||||
}
|
||||
@@ -214,7 +209,6 @@ const Console = () => {
|
||||
canControl,
|
||||
controlRegionMode,
|
||||
inputRegion,
|
||||
resolution,
|
||||
setControlRegionModeState,
|
||||
setInputRegion,
|
||||
videoMode
|
||||
@@ -234,7 +228,7 @@ const Console = () => {
|
||||
{isBigScreen && <Notification />}
|
||||
<H264ModeNotification />
|
||||
|
||||
{videoMode && resolution && (
|
||||
{videoMode && (
|
||||
<div className="relative flex h-full min-h-0 w-full min-w-0">
|
||||
<Menu />
|
||||
<div className="h-full min-h-0 w-full min-w-0">
|
||||
|
||||
@@ -56,11 +56,16 @@ export const Keyboard = () => {
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
document.addEventListener('compositionstart', handleCompositionStart);
|
||||
document.addEventListener('compositionend', handleCompositionEnd);
|
||||
document.addEventListener('focusin', handleFocusIn);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!isKeyboardEnabled) return;
|
||||
// An input, a textarea or a select owns the keyboard while it has focus.
|
||||
// Forwarding the key and cancelling its default is what makes a paste
|
||||
// land nowhere: the browser never gets to insert the text.
|
||||
if (isEditableTarget(event.target)) return;
|
||||
if (isComposing.current || event.isComposing) return;
|
||||
|
||||
event.preventDefault();
|
||||
@@ -86,12 +91,18 @@ export const Keyboard = () => {
|
||||
if (!isKeyboardEnabled) return;
|
||||
if (isComposing.current || event.isComposing) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const code = normalizeKeyCode(event, os);
|
||||
if (!code) return;
|
||||
|
||||
// An input owns the keyboard while it has focus, but a key that was
|
||||
// already forwarded has to be released anyway: dropping its keyup leaves
|
||||
// the target machine holding it down, and every later key carries the
|
||||
// modifier.
|
||||
if (isEditableTarget(event.target) && !pressedKeys.current.has(code)) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Handle leader key release
|
||||
const leaderHandled = leaderKeyRef.current.handleKeyUp(code);
|
||||
if (leaderHandled) {
|
||||
@@ -113,6 +124,16 @@ export const Keyboard = () => {
|
||||
sendKeyEvent('keyup', code);
|
||||
}
|
||||
|
||||
// Focus moving into a text control must not leave a modifier held on the
|
||||
// target machine: the matching keyup lands in the input, which is not
|
||||
// forwarded, so it is released here instead.
|
||||
function handleFocusIn(event: FocusEvent) {
|
||||
if (!isKeyboardEnabled) return;
|
||||
if (isEditableTarget(event.target)) {
|
||||
releaseKeys();
|
||||
}
|
||||
}
|
||||
|
||||
function handleCompositionStart() {
|
||||
isComposing.current = true;
|
||||
}
|
||||
@@ -157,6 +178,7 @@ export const Keyboard = () => {
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
document.removeEventListener('compositionstart', handleCompositionStart);
|
||||
document.removeEventListener('compositionend', handleCompositionEnd);
|
||||
document.removeEventListener('focusin', handleFocusIn);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
@@ -175,3 +197,17 @@ export const Keyboard = () => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// isEditableTarget reports whether the event belongs to a control that takes
|
||||
// text: forwarding those keys to the target machine steals typing and pasting
|
||||
// from the field the user is filling in.
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
|
||||
return (
|
||||
target.isContentEditable ||
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { updateScreen } from '@/api/vm';
|
||||
import * as ls from '@/lib/localstorage';
|
||||
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
|
||||
import { videoModeAtom } from '@/jotai/screen.ts';
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
import { getQualityMap, getScreenType } from './constants.ts';
|
||||
@@ -14,7 +14,6 @@ import { FrameDetect } from './frame-detect';
|
||||
import { Gop } from './gop.tsx';
|
||||
import { Quality } from './quality';
|
||||
import { Reset } from './reset.tsx';
|
||||
import { Resolution } from './resolution';
|
||||
import { Scale } from './scale';
|
||||
import { VideoMode } from './video-mode.tsx';
|
||||
|
||||
@@ -22,7 +21,6 @@ export const Screen = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const isMjpeg = videoMode === 'mjpeg';
|
||||
const [fps, setFps] = useState(30);
|
||||
const [quality, setQuality] = useState(2);
|
||||
@@ -33,7 +31,6 @@ export const Screen = () => {
|
||||
if (screenType !== null) {
|
||||
updateScreen('type', screenType);
|
||||
}
|
||||
updateScreen('resolution', resolution!.height);
|
||||
updateQuality();
|
||||
updateFps();
|
||||
updateGop();
|
||||
@@ -77,7 +74,6 @@ export const Screen = () => {
|
||||
const content = (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<VideoMode />
|
||||
<Resolution />
|
||||
<Quality quality={quality} setQuality={setQuality} />
|
||||
<Fps fps={fps} setFps={setFps} />
|
||||
<Scale />
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import { CheckIcon, CircleHelpIcon, RatioIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { updateScreen } from '@/api/vm';
|
||||
import { Resolution as TypeResolution } from '@/types';
|
||||
import { setResolution as setCookie } from '@/lib/localstorage';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
const resolutions: TypeResolution[] = [
|
||||
{ width: 0, height: 0 },
|
||||
{ width: 1920, height: 1080 },
|
||||
{ width: 1280, height: 720 },
|
||||
{ width: 800, height: 600 },
|
||||
{ width: 640, height: 480 }
|
||||
];
|
||||
|
||||
export const Resolution = () => {
|
||||
const { t } = useTranslation();
|
||||
const [resolution, setResolution] = useAtom(resolutionAtom);
|
||||
|
||||
async function update(item: TypeResolution) {
|
||||
const rsp = await updateScreen('resolution', item.height);
|
||||
if (rsp.code !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResolution(item);
|
||||
setCookie(item);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{resolutions.map((res) => (
|
||||
<div
|
||||
key={res.height}
|
||||
className="flex cursor-pointer select-none items-center rounded py-1.5 pl-1 pr-5 hover:bg-neutral-700/70"
|
||||
onClick={() => update(res)}
|
||||
>
|
||||
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
|
||||
{res.height === resolution?.height && <CheckIcon size={15} />}
|
||||
</div>
|
||||
|
||||
{res.height === 0 ? (
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<span>{t('screen.auto')}</span>
|
||||
<Tooltip
|
||||
title={t('screen.autoTips')}
|
||||
placement="right"
|
||||
overlayInnerStyle={{ width: '300px' }}
|
||||
>
|
||||
<CircleHelpIcon size={14} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span>{res.width}</span>
|
||||
<span className="px-1">x</span>
|
||||
<span>{res.height}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<RatioIcon size={18} />
|
||||
<span className="select-none text-sm">{t('screen.resolution')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -1,60 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '@/contexts/auth.ts';
|
||||
import { Divider, Segmented, Tooltip } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
DiscIcon,
|
||||
ExternalLinkIcon,
|
||||
HardDriveIcon,
|
||||
LoaderCircleIcon,
|
||||
PlugIcon,
|
||||
UnplugIcon
|
||||
} from 'lucide-react';
|
||||
import { Modal } from 'antd';
|
||||
import { FolderSyncIcon, HardDriveIcon, XIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/media.ts';
|
||||
import { MediaIcon } from '@/lib/media-icons.tsx';
|
||||
import { useMediaMount } from '@/hooks/useMediaMount.ts';
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
import { ButtonBase } from '@/components/ui/button-base.tsx';
|
||||
import { StorageView } from '@/pages/storage/index.tsx';
|
||||
|
||||
// The desktop menu keeps the actions that belong next to the screen - mount
|
||||
// and eject - and hands everything else to the storage page, which opens in
|
||||
// its own tab.
|
||||
type View = 'mount' | 'files';
|
||||
|
||||
// The menu entry is only a chooser: disk mounting and file sharing each open
|
||||
// their own settings-style window, so the popover stays a small picker.
|
||||
export const Storage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { hasPermission } = useAuth();
|
||||
const canAttach = hasPermission('medium.attach');
|
||||
// The virtual disk is a device setting, so whether the hint can point at an
|
||||
// enabled switch depends on this grant.
|
||||
const canDevice = hasPermission('device');
|
||||
|
||||
const [media, setMedia] = useState<api.MediaItem[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [state, setState] = useState<api.MediaStateInfo>({
|
||||
state: 'none',
|
||||
hidOnly: false,
|
||||
open: [],
|
||||
cdrom: false
|
||||
});
|
||||
const [mode, setMode] = useState<'mass-storage' | 'cd-rom'>('mass-storage');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mount, eject, busy } = useMediaMount({
|
||||
onError: (message, description) =>
|
||||
setError(description ? `${message} — ${description}` : message),
|
||||
onNeedEnableDisk: () =>
|
||||
setError(t(canDevice ? 'storage.enableDisk.menuHint' : 'storage.enableDisk.askDevice')),
|
||||
onNeedForce: () => setError(t('storage.force.menuHint')),
|
||||
onChanged: () => void refresh()
|
||||
cdrom: false,
|
||||
localWritable: true
|
||||
});
|
||||
const [modalView, setModalView] = useState<View | null>(null);
|
||||
// Bumped when a card opens its window: the picker closes, the menu bar stays.
|
||||
const [pickerCloseRequest, setPickerCloseRequest] = useState(0);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const rsp = await api.getMedia();
|
||||
if (rsp.code === 0) {
|
||||
setMedia(rsp.data?.media ?? []);
|
||||
}
|
||||
|
||||
const stateRsp = await api.getMediaState();
|
||||
if (stateRsp.code === 0) {
|
||||
setState(stateRsp.data);
|
||||
@@ -68,165 +41,103 @@ export const Storage = () => {
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
// The list can change while the target machine owns the partition, so it is
|
||||
// re-read while the panel is open. Only then: listing the media while the
|
||||
// target machine owns the partition makes the server drop its caches, which
|
||||
// is not worth paying for a panel nobody is looking at.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const timer = setInterval(() => void refresh(), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [isOpen]);
|
||||
|
||||
// The mode control follows the attached medium, so it shows what the target
|
||||
// machine is actually looking at instead of the last choice made here.
|
||||
useEffect(() => {
|
||||
if (state.state === 'image' || state.state === 'partition') {
|
||||
setMode(state.cdrom ? 'cd-rom' : 'mass-storage');
|
||||
}
|
||||
}, [state.state, state.cdrom]);
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
setIsOpen(open);
|
||||
if (open) void refresh();
|
||||
}
|
||||
|
||||
// The partition's name is a fixed string in the API, so the panel names it
|
||||
// the way the storage page does instead of showing the untranslated one.
|
||||
function mediaName(item: api.MediaItem): string {
|
||||
return item.type === 'partition' ? t('storage.typePartition') : item.name;
|
||||
// The picker hands over to the window. It asks to close through the item's
|
||||
// own request rather than the global menu signal: that one collapses the menu
|
||||
// bar, and the bar has no business disappearing because a window opened.
|
||||
function openView(view: View) {
|
||||
setPickerCloseRequest((request) => request + 1);
|
||||
setModalView(view);
|
||||
}
|
||||
|
||||
function openPage() {
|
||||
window.open('/#storage', '_blank');
|
||||
}
|
||||
const unavailable = state.state === 'unsupported';
|
||||
|
||||
const attachedName =
|
||||
state.state === 'partition'
|
||||
? t('storage.typePartition')
|
||||
: media.find((item) => item.type === 'image' && item.rel === state.rel)?.name;
|
||||
|
||||
const modes = [
|
||||
{
|
||||
value: 'mass-storage',
|
||||
label: (
|
||||
<div className="flex items-center space-x-1">
|
||||
<HardDriveIcon size={14} />
|
||||
<span>{t('storage.mountModeDisk')}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'cd-rom',
|
||||
label: (
|
||||
<div className="flex items-center space-x-1">
|
||||
<DiscIcon size={14} />
|
||||
<span>{t('storage.mountModeCdrom')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const cards: { view: View; icon: typeof HardDriveIcon; label: string }[] = [
|
||||
{ view: 'mount', icon: HardDriveIcon, label: t('storage.cards.mount') },
|
||||
{ view: 'files', icon: FolderSyncIcon, label: t('storage.cards.share') }
|
||||
];
|
||||
|
||||
const closeButton = (
|
||||
<ButtonBase
|
||||
aria-label={t('storage.close')}
|
||||
onClick={() => setModalView(null)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-white/45 transition-colors hover:bg-white/[0.06] hover:text-white/85"
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</ButtonBase>
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
title={t('storage.title')}
|
||||
icon={<HardDriveIcon size={18} />}
|
||||
fresh={state.state !== 'none'}
|
||||
onOpenChange={handleOpenChange}
|
||||
content={
|
||||
<div className="flex w-[300px] flex-col space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-400">
|
||||
{state.state === 'none' || state.state === 'unsupported'
|
||||
? t('storage.notAttached')
|
||||
: t('storage.attached', { name: attachedName ?? '' })}
|
||||
</span>
|
||||
|
||||
{canAttach && state.state !== 'none' && state.state !== 'unsupported' && (
|
||||
<Tooltip title={t('storage.eject')}>
|
||||
<div
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-neutral-400 hover:bg-neutral-700 hover:text-white"
|
||||
onClick={() => void eject()}
|
||||
>
|
||||
{busy === 'eject' ? (
|
||||
<LoaderCircleIcon size={14} className="animate-spin" />
|
||||
) : (
|
||||
<UnplugIcon size={14} />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<>
|
||||
<MenuItem
|
||||
title={t('storage.title')}
|
||||
icon={<HardDriveIcon size={18} />}
|
||||
fresh={state.state !== 'none'}
|
||||
onOpenChange={handleOpenChange}
|
||||
closeRequest={pickerCloseRequest}
|
||||
content={
|
||||
<div className="flex w-[320px] flex-col gap-3">
|
||||
{unavailable && (
|
||||
<span className="text-xs text-neutral-500">
|
||||
{state.hidOnly ? t('storage.hidOnly') : t('storage.disabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.state === 'unsupported' && (
|
||||
<span className="text-xs text-neutral-500">
|
||||
{state.hidOnly ? t('storage.hidOnly') : t('storage.disabled')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canAttach && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">{t('storage.mountMode')}</span>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={mode}
|
||||
options={modes}
|
||||
onChange={(value) => setMode(value as 'mass-storage' | 'cd-rom')}
|
||||
disabled={state.state === 'unsupported' || busy !== ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
|
||||
<div className="flex max-h-[240px] flex-col space-y-1 overflow-auto">
|
||||
{media.map((item) => {
|
||||
const key = `${item.type}:${item.rel}`;
|
||||
const isBusy = busy === key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={clsx(
|
||||
'group flex h-[28px] items-center justify-between rounded px-2 hover:bg-neutral-700/70',
|
||||
item.attached && 'text-blue-500'
|
||||
)}
|
||||
onClick={() => canAttach && !item.attached && void mount(item, mode === 'cd-rom')}
|
||||
{/* The cards stay openable even with the virtual disk switched
|
||||
off: that switch is how the data partition is handed back for
|
||||
writing, so it is exactly the state an image upload starts
|
||||
from. The window itself says the disk is disabled and only
|
||||
its attach actions are unavailable. */}
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{cards.map((card) => (
|
||||
<ButtonBase
|
||||
key={card.view}
|
||||
onClick={() => openView(card.view)}
|
||||
className="flex w-full flex-col items-center gap-2.5 rounded-xl border border-white/10 bg-white/[0.04] py-5 text-neutral-300 transition-colors enabled:hover:border-white/20 enabled:hover:bg-white/[0.08] enabled:hover:text-neutral-100 disabled:pointer-events-none"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center space-x-2">
|
||||
<MediaIcon item={item} />
|
||||
<span className="truncate text-sm" title={mediaName(item)}>
|
||||
{mediaName(item)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canAttach && (
|
||||
<div className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{isBusy ? (
|
||||
<LoaderCircleIcon size={14} className="animate-spin" />
|
||||
) : (
|
||||
!item.attached && <PlugIcon size={14} className="text-neutral-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<card.icon size={24} strokeWidth={1.8} className="text-blue-400" />
|
||||
<span className="text-sm">{card.label}</span>
|
||||
</ButtonBase>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
{/* Keep the mount view alive so closing the window preserves uploads and their progress. */}
|
||||
<Modal
|
||||
open={modalView === 'mount'}
|
||||
width={560}
|
||||
centered
|
||||
footer={null}
|
||||
closable={false}
|
||||
forceRender
|
||||
destroyOnHidden={false}
|
||||
onCancel={() => setModalView(null)}
|
||||
styles={{ content: { padding: 0, overflow: 'hidden' } }}
|
||||
>
|
||||
<StorageView view="mount" active={modalView === 'mount'} headerExtra={closeButton} />
|
||||
</Modal>
|
||||
|
||||
{error && <span className="break-words text-xs text-red-500">{error}</span>}
|
||||
|
||||
<div
|
||||
className="flex cursor-pointer items-center space-x-1 rounded px-2 py-1 text-sm hover:bg-neutral-700/70"
|
||||
onClick={openPage}
|
||||
>
|
||||
<ExternalLinkIcon size={14} />
|
||||
<span>{t('storage.openManager')}</span>
|
||||
</div>
|
||||
<Modal
|
||||
open={modalView === 'files'}
|
||||
width="80%"
|
||||
centered
|
||||
footer={null}
|
||||
closable={false}
|
||||
forceRender
|
||||
destroyOnHidden={false}
|
||||
onCancel={() => setModalView(null)}
|
||||
style={{ maxWidth: '1080px' }}
|
||||
styles={{ content: { padding: 0, overflow: 'hidden' } }}
|
||||
>
|
||||
<div className="h-[80vh] max-h-[700px]">
|
||||
<StorageView view="files" active={modalView === 'files'} headerExtra={closeButton} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAtomValue } from 'jotai';
|
||||
import { MouseReportAbsolute } from '@/lib/mouse.ts';
|
||||
import { client, MessageEvent } from '@/lib/websocket.ts';
|
||||
import { scrollDirectionAtom, scrollIntervalAtom } from '@/jotai/mouse.ts';
|
||||
import { inputRegionAtom, resolutionAtom } from '@/jotai/screen.ts';
|
||||
import { inputRegionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import {
|
||||
FrameContent,
|
||||
@@ -25,7 +25,6 @@ enum MouseButton {
|
||||
}
|
||||
|
||||
export const Absolute = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const inputRegion = useAtomValue(inputRegionAtom);
|
||||
const scrollDirection = useAtomValue(scrollDirectionAtom);
|
||||
const scrollInterval = useAtomValue(scrollIntervalAtom);
|
||||
@@ -426,9 +425,7 @@ export const Absolute = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaSize =
|
||||
getMediaSize(target) ||
|
||||
(resolution && resolution.width > 0 && resolution.height > 0 ? resolution : null);
|
||||
const mediaSize = getMediaSize(target);
|
||||
if (!mediaSize) {
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
const y = (clientY - rect.top) / rect.height;
|
||||
@@ -531,7 +528,7 @@ export const Absolute = () => {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [inputRegion, resolution, scrollDirection, scrollInterval]);
|
||||
}, [inputRegion, scrollDirection, scrollInterval]);
|
||||
|
||||
// disable default events
|
||||
function disableEvent(event: Event) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { MouseReportRelative } from '@/lib/mouse.ts';
|
||||
import { client, MessageEvent } from '@/lib/websocket.ts';
|
||||
import { scrollDirectionAtom, scrollIntervalAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { MouseRelativeEvent } from './types.ts';
|
||||
|
||||
@@ -14,7 +13,6 @@ export const Relative = () => {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const scrollDirection = useAtomValue(scrollDirectionAtom);
|
||||
const scrollInterval = useAtomValue(scrollIntervalAtom);
|
||||
|
||||
@@ -328,7 +326,7 @@ export const Relative = () => {
|
||||
target.removeEventListener('touchcancel', handleTouchCancel, touchOptions.capture);
|
||||
clearTouchLongPressTimer();
|
||||
};
|
||||
}, [resolution, scrollDirection, scrollInterval]);
|
||||
}, [scrollDirection, scrollInterval]);
|
||||
|
||||
// Mouse handler
|
||||
function handleMouseEvent(event: MouseRelativeEvent) {
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
controlRegionModeAtom,
|
||||
inputRegionAtom,
|
||||
inputRegionSelectingAtom,
|
||||
resolutionAtom
|
||||
inputRegionSelectingAtom
|
||||
} from '@/jotai/screen.ts';
|
||||
|
||||
import { detectFrameContent, getMediaSize } from './geometry.ts';
|
||||
@@ -13,7 +12,6 @@ import { detectFrameContent, getMediaSize } from './geometry.ts';
|
||||
export const AutoRegion = () => {
|
||||
const mode = useAtomValue(controlRegionModeAtom);
|
||||
const selecting = useAtomValue(inputRegionSelectingAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const setInputRegion = useSetAtom(inputRegionAtom);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,7 +35,7 @@ export const AutoRegion = () => {
|
||||
confirmations = 0;
|
||||
}
|
||||
|
||||
const mediaSize = getMediaSize(screen, resolution);
|
||||
const mediaSize = getMediaSize(screen);
|
||||
if (!mediaSize) return;
|
||||
const content = detectFrameContent(screen, mediaSize);
|
||||
const key = [
|
||||
@@ -74,7 +72,7 @@ export const AutoRegion = () => {
|
||||
stopped = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [mode, resolution, selecting, setInputRegion]);
|
||||
}, [mode, selecting, setInputRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'auto') setInputRegion(null);
|
||||
|
||||
@@ -27,7 +27,7 @@ export const fullFrameContent = (mediaSize: MediaSize): FrameContent => ({
|
||||
height: mediaSize.height
|
||||
});
|
||||
|
||||
export function getMediaSize(screen: Element, fallback?: MediaSize | null): MediaSize | null {
|
||||
export function getMediaSize(screen: Element): MediaSize | null {
|
||||
const dataWidth = Number(screen.getAttribute(mediaWidthAttribute));
|
||||
const dataHeight = Number(screen.getAttribute(mediaHeightAttribute));
|
||||
if (dataWidth > 0 && dataHeight > 0) {
|
||||
@@ -42,7 +42,7 @@ export function getMediaSize(screen: Element, fallback?: MediaSize | null): Medi
|
||||
return { width: screen.naturalWidth, height: screen.naturalHeight };
|
||||
}
|
||||
|
||||
return fallback && fallback.width > 0 && fallback.height > 0 ? fallback : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isMediaReady(screen: Element) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
inputRegionSelectingAtom,
|
||||
manualInputRegionAtom,
|
||||
manualRegionsAtom,
|
||||
resolutionAtom,
|
||||
selectedManualRegionAtom,
|
||||
selectedOriginalResolutionAtom,
|
||||
videoScaleAtom
|
||||
@@ -87,7 +86,6 @@ export const InputRegionOverlay = () => {
|
||||
const [, setInputRegionState] = useAtom(inputRegionAtom);
|
||||
const setManualInputRegion = useSetAtom(manualInputRegionAtom);
|
||||
const [manualRegions, setManualRegions] = useAtom(manualRegionsAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const setSelectedManualRegion = useSetAtom(selectedManualRegionAtom);
|
||||
const setSelectedOriginalResolution = useSetAtom(selectedOriginalResolutionAtom);
|
||||
const videoScale = useAtomValue(videoScaleAtom);
|
||||
@@ -166,7 +164,7 @@ export const InputRegionOverlay = () => {
|
||||
frameRectRef.current = null;
|
||||
return;
|
||||
}
|
||||
const nextMediaSize = getMediaSize(target, resolution);
|
||||
const nextMediaSize = getMediaSize(target);
|
||||
const bounds = target.getBoundingClientRect();
|
||||
if (!nextMediaSize || bounds.width <= 0 || bounds.height <= 0) {
|
||||
setFrameRect(null);
|
||||
@@ -206,7 +204,7 @@ export const InputRegionOverlay = () => {
|
||||
window.removeEventListener('scroll', updateFrame, true);
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [resolution, selecting, videoScale]);
|
||||
}, [selecting, videoScale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selecting) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
controlRegionModeAtom,
|
||||
inputRegionAtom,
|
||||
manualRegionsAtom,
|
||||
resolutionAtom,
|
||||
selectedManualRegionAtom,
|
||||
selectedOriginalResolutionAtom
|
||||
} from '@/jotai/screen.ts';
|
||||
@@ -17,7 +16,6 @@ export const ManualRegion = () => {
|
||||
const selectedOriginal = useAtomValue(selectedOriginalResolutionAtom);
|
||||
const selectedManual = useAtomValue(selectedManualRegionAtom);
|
||||
const manualRegions = useAtomValue(manualRegionsAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const setInputRegion = useSetAtom(inputRegionAtom);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,7 +42,7 @@ export const ManualRegion = () => {
|
||||
const update = () => {
|
||||
const target = document.getElementById('screen');
|
||||
if (!target) return;
|
||||
const mediaSize = getMediaSize(target, resolution);
|
||||
const mediaSize = getMediaSize(target);
|
||||
setInputRegion(
|
||||
mediaSize ? getCenteredInputRegionByAspectRatio(width, height, mediaSize) : null
|
||||
);
|
||||
@@ -54,7 +52,7 @@ export const ManualRegion = () => {
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [manualRegions, mode, resolution, selectedManual, selectedOriginal, setInputRegion]);
|
||||
}, [manualRegions, mode, selectedManual, selectedOriginal, setInputRegion]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -6,17 +6,14 @@ import { stopFrameDetect } from '@/api/stream.ts';
|
||||
import { getFrameDetect } from '@/lib/localstorage.ts';
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { ScreenViewport } from './viewport.tsx';
|
||||
|
||||
export const Mjpeg = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [streamNonce, setStreamNonce] = useState(0);
|
||||
const streamURL = `${getBaseUrl('http')}/api/stream/mjpeg`;
|
||||
const streamSrc = hasError ? undefined : `${streamURL}?v=${streamNonce}`;
|
||||
const streamSrc = hasError ? undefined : streamURL;
|
||||
|
||||
useEffect(() => {
|
||||
// stop frame detect for a while
|
||||
@@ -24,9 +21,7 @@ export const Mjpeg = () => {
|
||||
if (enabled) {
|
||||
stopFrameDetect(10);
|
||||
}
|
||||
setHasError(false);
|
||||
setStreamNonce((current) => current + 1);
|
||||
}, [resolution]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScreenViewport>
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAuth } from '@/contexts/auth.ts';
|
||||
import { Alert, App, Button, Collapse, Modal, notification, Segmented, Tooltip } from 'antd';
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
ConfigProvider,
|
||||
Modal,
|
||||
notification,
|
||||
Segmented,
|
||||
theme,
|
||||
Tooltip
|
||||
} from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
CheckIcon,
|
||||
CircleHelpIcon,
|
||||
DiscIcon,
|
||||
DownloadIcon,
|
||||
FolderSyncIcon,
|
||||
HardDriveIcon,
|
||||
LinkIcon,
|
||||
LoaderCircleIcon,
|
||||
PlugIcon,
|
||||
Trash2Icon,
|
||||
UnplugIcon,
|
||||
UploadIcon,
|
||||
XIcon
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -22,6 +34,7 @@ import { MediaIcon } from '@/lib/media-icons.tsx';
|
||||
import { WsClient } from '@/lib/websocket.ts';
|
||||
import { useMediaMount } from '@/hooks/useMediaMount.ts';
|
||||
import { Head } from '@/components/head.tsx';
|
||||
import { ButtonBase } from '@/components/ui/button-base.tsx';
|
||||
|
||||
import { useAddMedia } from './use-add-media.tsx';
|
||||
import { useFileBrowser, type FileUpload } from './use-file-browser.tsx';
|
||||
@@ -61,39 +74,56 @@ const MediaGroup = ({
|
||||
}) => (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="flex h-6 items-center justify-between px-2">
|
||||
<span className="text-xs uppercase tracking-wide text-neutral-500">{title}</span>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-neutral-300">{title}</span>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Storage = () => {
|
||||
type StorageViewProps = {
|
||||
// 'mount' is the disk-mount manager; 'files' is the shared-files browser.
|
||||
// The desktop menu opens each inside its own modal; the standalone page
|
||||
// switches between them with a segmented control.
|
||||
view: 'mount' | 'files';
|
||||
active: boolean;
|
||||
// Extra slot on the header's right side (the modals put their close button
|
||||
// there, the page puts its view switch).
|
||||
headerExtra?: ReactNode;
|
||||
// The page fills the window; a modal sizes itself.
|
||||
fullPage?: boolean;
|
||||
};
|
||||
|
||||
const StorageContent = ({ view, active, headerExtra, fullPage }: StorageViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { hasPermission } = useAuth();
|
||||
const { token } = theme.useToken();
|
||||
const [notificationApi, contextHolder] = notification.useNotification();
|
||||
const { modal } = App.useApp();
|
||||
|
||||
// Attaching a medium hands it to the target machine; writing changes what the
|
||||
// library keeps. Reading is implied by both, so the page itself is already
|
||||
// behind library.read.
|
||||
const canAttach = hasPermission('medium.attach');
|
||||
const canWrite = hasPermission('library.write');
|
||||
// Turning the virtual disk on is a device setting, so only an account that may
|
||||
// change device settings can do it from here.
|
||||
// Turning the virtual disk on is a device setting, so only an account that
|
||||
// may change device settings can do it from here.
|
||||
const canDevice = hasPermission('device');
|
||||
const [notificationApi, contextHolder] = notification.useNotification();
|
||||
const { modal } = App.useApp();
|
||||
// The storage push rides on the console socket, so an account without the
|
||||
// console grant waits for the refresh button instead.
|
||||
const canWatchConsole = hasPermission('console.view');
|
||||
|
||||
const [media, setMedia] = useState<api.MediaItem[]>([]);
|
||||
const [caps, setCaps] = useState<api.MediaCapabilities>({ loop: true });
|
||||
const [state, setState] = useState<api.MediaStateInfo>({
|
||||
state: 'none',
|
||||
hidOnly: false,
|
||||
open: [],
|
||||
cdrom: false
|
||||
cdrom: false,
|
||||
localWritable: true
|
||||
});
|
||||
// The mode the next mount uses. It follows what is attached, so the control
|
||||
// shows the mode the target machine is looking at rather than a stale choice.
|
||||
const [mountAsCdrom, setMountAsCdrom] = useState(false);
|
||||
const [selected, setSelected] = useState<string>(''); // "<type>:<rel>"
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [storageSignal, setStorageSignal] = useState(0);
|
||||
const [job, setJob] = useState<api.MediaStatus | null>(null);
|
||||
@@ -105,10 +135,12 @@ export const Storage = () => {
|
||||
} | null>(null);
|
||||
const lastStatus = useRef<api.MediaStatus['status']>('idle');
|
||||
const [dismissedJob, setDismissedJob] = useState(readDismissedJob);
|
||||
const [isTipsOpen, setIsTipsOpen] = useState(false);
|
||||
// Names of file uploads the user cancelled: the server records the aborted
|
||||
// request as a failed job, which is not news to the user who stopped it.
|
||||
// Names of file uploads whose failure the page has already explained: a
|
||||
// cancelled request and a name that is already taken both leave a failed
|
||||
// media job behind, and the job is not news to a user who has been told.
|
||||
const cancelledUploads = useRef(new Set<string>());
|
||||
const activeRef = useRef(active);
|
||||
activeRef.current = active;
|
||||
|
||||
// Both callbacks are handed to children that poll with them in their
|
||||
// dependency lists: a new identity on every render would tear the intervals
|
||||
@@ -128,30 +160,36 @@ export const Storage = () => {
|
||||
// network dropping) is left to the next round instead of raising a
|
||||
// notification every few seconds.
|
||||
const refresh = useCallback(async () => {
|
||||
if (!activeRef.current) return;
|
||||
try {
|
||||
const rsp = await api.getMedia();
|
||||
if (rsp.code === 0) {
|
||||
setMedia(rsp.data?.media ?? []);
|
||||
setCaps(rsp.data?.capabilities ?? { loop: true });
|
||||
}
|
||||
|
||||
// State recovery recreates /data, so list files from the fresh mount.
|
||||
const stateRsp = await api.getMediaState();
|
||||
if (!activeRef.current) return;
|
||||
if (stateRsp.code === 0) {
|
||||
setState(stateRsp.data);
|
||||
}
|
||||
|
||||
const rsp = await api.getMedia();
|
||||
if (!activeRef.current) return;
|
||||
if (rsp.code === 0) {
|
||||
setMedia(rsp.data?.media ?? []);
|
||||
}
|
||||
} catch {
|
||||
// Retried by the poll.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshMedia = useCallback(() => void refresh(), [refresh]);
|
||||
const refreshMedia = useCallback(async () => {
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
void refresh().finally(() => setIsLoading(false));
|
||||
|
||||
const timer = setInterval(() => void refresh(), statePollInterval);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
}, [active, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.state === 'image' || state.state === 'partition') {
|
||||
@@ -159,20 +197,12 @@ export const Storage = () => {
|
||||
}
|
||||
}, [state.state, state.cdrom]);
|
||||
|
||||
// The server refreshes the shared files view on a timer while the target
|
||||
// machine owns the partition and pushes a storage event, which is how the
|
||||
// file listing stays current. The medium list keeps its own poll above: a
|
||||
// refresh only means the files changed, and re-walking the medium directory
|
||||
// on every one of those events would cost more than it shows.
|
||||
//
|
||||
// The socket is the console one, so an account granted only the library has
|
||||
// none to open. The medium list keeps its own poll either way; without the
|
||||
// push the file listing waits for the refresh button.
|
||||
// Depend on the grant, not on hasPermission: that changes identity with every
|
||||
// account refresh, which would tear the socket down on each auth push.
|
||||
const canWatchConsole = hasPermission('console.view');
|
||||
// The server pushes "the shared files changed" over the console socket while
|
||||
// the target machine owns the partition. Depend on the grant, not on
|
||||
// hasPermission: that changes identity with every account refresh, which
|
||||
// would tear the socket down on each auth push.
|
||||
useEffect(() => {
|
||||
if (!canWatchConsole) return;
|
||||
if (!active || !canWatchConsole || view !== 'files') return;
|
||||
|
||||
const ws = new WsClient();
|
||||
ws.connect();
|
||||
@@ -181,11 +211,28 @@ export const Storage = () => {
|
||||
unsubscribe();
|
||||
ws.close();
|
||||
};
|
||||
}, [canWatchConsole]);
|
||||
}, [active, canWatchConsole, view]);
|
||||
|
||||
// The push above rides on a socket an account may not have. Without it the
|
||||
// listing only ever changes when the user asks, which reads as a broken file
|
||||
// manager while the target machine writes: poll the way the NanoKVM Go file
|
||||
// manager does instead. Only while the target machine owns the partition is
|
||||
// there anything to see.
|
||||
useEffect(() => {
|
||||
if (!active || canWatchConsole || view !== 'files' || state.state !== 'partition') return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
setStorageSignal((signal) => signal + 1);
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [active, canWatchConsole, view, state.state]);
|
||||
|
||||
// Poll the media job while one is running: creating a medium writes
|
||||
// gigabytes to the card, so the page reports it like any other transfer.
|
||||
// Only the file manager runs such a job, so only it polls.
|
||||
useEffect(() => {
|
||||
if (!active || view !== 'files') return;
|
||||
let cancelled = false;
|
||||
|
||||
async function poll() {
|
||||
@@ -218,11 +265,10 @@ export const Storage = () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [refresh]);
|
||||
}, [active, refresh, view]);
|
||||
|
||||
// Operations the target machine can refuse are confirmed here rather than in
|
||||
// the menu panel: this page is a full window, so a dialog cannot be
|
||||
// swallowed by a popover closing.
|
||||
// the menu panel: a dialog cannot be swallowed by a popover closing.
|
||||
const { mount, eject, busy } = useMediaMount({
|
||||
onError: notifyError,
|
||||
onNeedEnableDisk: canDevice
|
||||
@@ -255,51 +301,24 @@ export const Storage = () => {
|
||||
// from writing anything: the image uploads and the file uploads inside a
|
||||
// medium both land on that one filesystem, so they share this condition.
|
||||
const dataOwned = state.state === 'partition';
|
||||
// Both reasons a write would be refused: the target machine owns the disk, or
|
||||
// the mount has not been handed back yet. The controls that write ask this.
|
||||
const writeBlocked = dataOwned || !state.localWritable;
|
||||
|
||||
const addMedia = useAddMedia({
|
||||
dataOwned,
|
||||
writeBlocked,
|
||||
onChanged: refreshMedia,
|
||||
onError: notifyError
|
||||
});
|
||||
|
||||
const volumeMedia = useMemo(() => media.filter((item) => item.type === 'partition'), [media]);
|
||||
const images = useMemo(() => media.filter((item) => item.type === 'image'), [media]);
|
||||
|
||||
const selectedMedia = useMemo(
|
||||
() => media.find((item) => `${item.type}:${item.rel}` === selected) ?? null,
|
||||
[media, selected]
|
||||
// The partition is the one medium the file browser shows: it is what the
|
||||
// emulated disk serves by default, and it needs no loop device to be read.
|
||||
const sharedPartition = useMemo(
|
||||
() => media.find((item) => item.type === 'partition') ?? null,
|
||||
[media]
|
||||
);
|
||||
|
||||
// The listing and the toolbar above it read the same folder, and switching
|
||||
// medium always starts at its root.
|
||||
useEffect(() => {
|
||||
setBrowsePath('/');
|
||||
}, [selected]);
|
||||
|
||||
// Why the file browser is unavailable, if it is: each reason has its own
|
||||
// sentence, because "you have to eject it on the target machine first" and
|
||||
// "this image holds no browsable filesystem" are different problems. An ISO
|
||||
// is the third case: it is a filesystem the kernel of this device does not
|
||||
// carry (CONFIG_ISO9660_FS), so its contents cannot be listed even though the
|
||||
// image itself is served to the target machine like any other medium.
|
||||
const browseBlockReason = ((): 'attached' | 'notBrowsable' | 'isoImage' | 'noLoop' | '' => {
|
||||
if (!selectedMedia) return '';
|
||||
|
||||
// The data partition is a directory on this device: it needs no loop
|
||||
// device, and it can be read in every state, including while the target
|
||||
// machine has it. Only writing to it has to wait for an eject.
|
||||
if (selectedMedia.type === 'partition') return '';
|
||||
|
||||
if (!caps.loop) return 'noLoop';
|
||||
if (!selectedMedia.browsable) {
|
||||
return api.isOpticalImage(selectedMedia) ? 'isoImage' : 'notBrowsable';
|
||||
}
|
||||
// An image is mounted as a block device to be read, and a block device the
|
||||
// target machine is writing underneath is not something to browse.
|
||||
if (selectedMedia.attached) return 'attached';
|
||||
return '';
|
||||
})();
|
||||
|
||||
// A download the browser starts on its own saves whatever comes back, so a
|
||||
// refused request ends up as a file named after the URL. The status is asked
|
||||
// for first - one byte of the image, aborted as soon as the headers arrive -
|
||||
@@ -356,38 +375,26 @@ export const Storage = () => {
|
||||
notifyError(rsp.msg);
|
||||
return;
|
||||
}
|
||||
if (selected === `${item.type}:${item.rel}`) setSelected('');
|
||||
await refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const browser = useFileBrowser({
|
||||
media: selectedMedia,
|
||||
share: volumeMedia[0] ?? null,
|
||||
reason: browseBlockReason,
|
||||
active: active && view === 'files',
|
||||
// The file browser shows the shared partition; the mount view never
|
||||
// browses, and passing null skips its listing poll.
|
||||
media: sharedPartition,
|
||||
dataOwned,
|
||||
localWritable: state.localWritable,
|
||||
path: browsePath,
|
||||
onPathChange: setBrowsePath,
|
||||
changeSignal: storageSignal,
|
||||
onMediaChanged: refreshMedia,
|
||||
onNameTaken: (name) => cancelledUploads.current.add(name),
|
||||
onError: notifyError
|
||||
});
|
||||
|
||||
const attachedName = useMemo(() => {
|
||||
if (state.state === 'partition') {
|
||||
return t('storage.typePartition');
|
||||
}
|
||||
if (state.state === 'image') {
|
||||
return (
|
||||
media.find((item) => item.type === 'image' && item.rel === state.rel)?.name ??
|
||||
state.rel ??
|
||||
''
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}, [media, state, t]);
|
||||
|
||||
// Every transfer the page runs is shown at the top in one shape: the media
|
||||
// job (a file written into a medium) and an image arriving from the browser
|
||||
// or a URL. They used to be two differently styled hints in two places.
|
||||
@@ -488,89 +495,62 @@ export const Storage = () => {
|
||||
? t('storage.add.uploading', { name: addTransfer.name })
|
||||
: t('storage.add.downloading', { name: addTransfer.name });
|
||||
|
||||
const tipItems = [
|
||||
{
|
||||
key: 'usb',
|
||||
label: 'USB',
|
||||
children: (
|
||||
<ul className="list-decimal pl-5">
|
||||
<li>{t('storage.tips.usb1')}</li>
|
||||
<li>{t('storage.tips.usb2')}</li>
|
||||
<li>{t('storage.tips.usb3')}</li>
|
||||
<li>{t('storage.tips.usb4')}</li>
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'scp',
|
||||
label: 'SCP',
|
||||
children: (
|
||||
<ul className="list-decimal pl-5">
|
||||
<li>{t('storage.tips.scp1')}</li>
|
||||
<li>{t('storage.tips.scp2')}</li>
|
||||
<li>{t('storage.tips.scp3')}</li>
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'tf',
|
||||
label: t('storage.tips.tfCard'),
|
||||
children: (
|
||||
<>
|
||||
<p className="text-sm text-neutral-400">{t('storage.tips.tf1')}</p>
|
||||
<ul className="list-decimal pl-5">
|
||||
<li>{t('storage.tips.tf2')}</li>
|
||||
<li>{t('storage.tips.tf3')}</li>
|
||||
<li>{t('storage.tips.tf4')}</li>
|
||||
<li>{t('storage.tips.tf5')}</li>
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
];
|
||||
// A write the target machine would refuse is explained once, above the file
|
||||
// manager: the buttons there are disabled, and a tooltip per button does not
|
||||
// say why all of them are.
|
||||
const filesReadOnlyHint = view === 'files' && dataOwned && (
|
||||
<div className="shrink-0 pb-3 text-xs text-neutral-400">
|
||||
{t('storage.files.reasonAttached')}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-neutral-950 p-6 text-neutral-200">
|
||||
<Head title={t('head.storage')} />
|
||||
<div
|
||||
className={clsx(
|
||||
'box-border w-full text-neutral-200',
|
||||
view === 'files'
|
||||
? 'flex h-full min-h-0 flex-col overflow-hidden p-4 sm:p-5'
|
||||
: 'overflow-auto p-4 sm:p-5',
|
||||
fullPage && 'h-full'
|
||||
)}
|
||||
style={{ backgroundColor: token.colorBgElevated }}
|
||||
>
|
||||
{contextHolder}
|
||||
{addMedia.overlays}
|
||||
{browser.overlays}
|
||||
|
||||
<div className="mx-auto flex h-full max-w-6xl flex-col">
|
||||
<div className="flex items-center justify-between pb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<HardDriveIcon size={18} />
|
||||
<span className="text-lg font-bold">{t('storage.title')}</span>
|
||||
<div
|
||||
className={clsx(
|
||||
'mx-auto flex min-h-0 flex-col',
|
||||
view === 'files' ? 'h-full w-full max-w-6xl' : 'max-w-xl',
|
||||
fullPage && view === 'mount' && 'h-full'
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center text-neutral-300">
|
||||
{view === 'mount' ? <HardDriveIcon size={20} /> : <FolderSyncIcon size={20} />}
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-neutral-100">
|
||||
{view === 'mount' ? t('storage.cards.mount') : t('storage.cards.share')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 text-sm">
|
||||
{state.state === 'unsupported' ? (
|
||||
<span className="text-neutral-500">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{state.state === 'unsupported' && (
|
||||
<span className="rounded-full border border-solid border-neutral-600 px-3 py-1 text-xs text-neutral-300">
|
||||
{state.hidOnly ? t('storage.hidOnly') : t('storage.disabled')}
|
||||
</span>
|
||||
) : state.state === 'none' ? (
|
||||
<span className="text-neutral-500">{t('storage.notAttached')}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-blue-500">
|
||||
{t('storage.attached', { name: attachedName })}
|
||||
</span>
|
||||
{canAttach && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UnplugIcon size={14} />}
|
||||
loading={busy === 'eject'}
|
||||
onClick={() => void eject()}
|
||||
>
|
||||
{t('storage.eject')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{headerExtra}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job &&
|
||||
{/* The media job reports one thing only: a file being written into the
|
||||
shared folder by the file manager. The mount window is a different
|
||||
surface, so a transfer started over there must not appear here. */}
|
||||
{view === 'files' &&
|
||||
job &&
|
||||
showJob &&
|
||||
renderTransfer({
|
||||
key: 'job',
|
||||
@@ -583,17 +563,18 @@ export const Storage = () => {
|
||||
onCancel: jobUpload ? () => cancelUpload(jobUpload) : undefined,
|
||||
onClose: () => dismissJob(jobKey(job))
|
||||
})}
|
||||
{waitingUploads.map((upload) =>
|
||||
renderTransfer({
|
||||
key: `upload-${upload.id}`,
|
||||
label: t('storage.add.uploading', { name: upload.name }),
|
||||
percentage: '',
|
||||
loaded: 0,
|
||||
total: 0,
|
||||
state: 'running',
|
||||
onCancel: () => cancelUpload(upload)
|
||||
})
|
||||
)}
|
||||
{view === 'files' &&
|
||||
waitingUploads.map((upload) =>
|
||||
renderTransfer({
|
||||
key: `upload-${upload.id}`,
|
||||
label: t('storage.add.uploading', { name: upload.name }),
|
||||
percentage: '',
|
||||
loaded: 0,
|
||||
total: 0,
|
||||
state: 'running',
|
||||
onCancel: () => cancelUpload(upload)
|
||||
})
|
||||
)}
|
||||
{addTransfer &&
|
||||
renderTransfer({
|
||||
key: `add-${addTransfer.id}`,
|
||||
@@ -612,23 +593,6 @@ export const Storage = () => {
|
||||
onClose: addMedia.dismissTransfer
|
||||
})}
|
||||
|
||||
<Modal
|
||||
title={t('storage.tips.title')}
|
||||
open={isTipsOpen}
|
||||
width={520}
|
||||
footer={null}
|
||||
centered
|
||||
onCancel={() => setIsTipsOpen(false)}
|
||||
>
|
||||
<Collapse
|
||||
accordion
|
||||
items={tipItems}
|
||||
bordered={false}
|
||||
defaultActiveKey={['usb']}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={confirm?.title}
|
||||
open={!!confirm}
|
||||
@@ -644,137 +608,191 @@ export const Storage = () => {
|
||||
<p>{confirm?.description}</p>
|
||||
</Modal>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[320px_1fr] gap-6">
|
||||
<div className="flex min-h-0 flex-col space-y-3">
|
||||
{/* The mount mode only shapes an attach, so an account that cannot
|
||||
attach has nothing to pick here. */}
|
||||
{canAttach && (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<span className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
{t('storage.mountMode')}
|
||||
</span>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={mountAsCdrom ? 'cdrom' : 'disk'}
|
||||
disabled={busy !== '' || state.state === 'unsupported'}
|
||||
onChange={(value) => setMountAsCdrom(value === 'cdrom')}
|
||||
options={[
|
||||
{
|
||||
value: 'disk',
|
||||
label: (
|
||||
<div className="flex items-center space-x-1">
|
||||
<HardDriveIcon size={14} />
|
||||
<span>{t('storage.mountModeDisk')}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'cdrom',
|
||||
label: (
|
||||
<div className="flex items-center space-x-1">
|
||||
<DiscIcon size={14} />
|
||||
<span>{t('storage.mountModeCdrom')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{view === 'mount' ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="h-px bg-neutral-700" />
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col space-y-4 overflow-auto">
|
||||
{canAttach && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-neutral-300">
|
||||
{t('storage.mountMode')}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{[
|
||||
{
|
||||
value: 'disk',
|
||||
icon: HardDriveIcon,
|
||||
label: t('storage.mountModeDisk')
|
||||
},
|
||||
{
|
||||
value: 'cdrom',
|
||||
icon: DiscIcon,
|
||||
label: t('storage.mountModeCdrom')
|
||||
}
|
||||
].map((modeOption) => {
|
||||
const cdrom = modeOption.value === 'cdrom';
|
||||
const isSelected = mountAsCdrom === cdrom;
|
||||
const disabled =
|
||||
busy !== '' || state.state === 'image' || state.state === 'unsupported';
|
||||
return (
|
||||
<ButtonBase
|
||||
key={modeOption.value}
|
||||
disabled={disabled}
|
||||
onClick={() => setMountAsCdrom(cdrom)}
|
||||
className={clsx(
|
||||
'relative flex min-h-10 min-w-0 items-center justify-center gap-2 rounded-md px-2 py-2 text-sm transition-colors',
|
||||
isSelected
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-900 text-neutral-300 enabled:hover:bg-neutral-800 enabled:hover:text-white'
|
||||
)}
|
||||
>
|
||||
<modeOption.icon size={15} className="shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal break-words text-center leading-tight">
|
||||
{modeOption.label}
|
||||
</span>
|
||||
</ButtonBase>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-neutral-700" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-neutral-300">
|
||||
{t('storage.add.title')}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{[
|
||||
{
|
||||
icon: UploadIcon,
|
||||
label: t('storage.add.upload'),
|
||||
open: addMedia.openUpload
|
||||
},
|
||||
{
|
||||
icon: LinkIcon,
|
||||
label: t('storage.add.fromUrl'),
|
||||
open: addMedia.openUrl
|
||||
}
|
||||
].map((action) => (
|
||||
<Tooltip
|
||||
key={action.label}
|
||||
title={writeBlocked ? t('storage.add.blocked') : undefined}
|
||||
>
|
||||
<span className="block min-w-0">
|
||||
<ButtonBase
|
||||
disabled={addMedia.addDisabled}
|
||||
onClick={action.open}
|
||||
className="flex min-h-11 w-full min-w-0 items-center justify-center gap-2 rounded-md bg-neutral-900 px-2 py-2 text-center text-sm text-neutral-300 transition-colors enabled:hover:bg-neutral-800 enabled:hover:text-white disabled:text-neutral-400"
|
||||
>
|
||||
<action.icon size={16} className="shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal break-words leading-snug">
|
||||
{action.label}
|
||||
</span>
|
||||
</ButtonBase>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-neutral-700" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex max-h-[55vh] flex-col gap-4 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center text-neutral-500">
|
||||
<div className="flex items-center justify-center py-6 text-neutral-400">
|
||||
<LoaderCircleIcon size={18} className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<MediaGroup title={t('storage.groups.share')} action={browser.toolbar}>
|
||||
{renderRows(volumeMedia)}
|
||||
</MediaGroup>
|
||||
|
||||
<MediaGroup
|
||||
title={t('storage.groups.images')}
|
||||
action={
|
||||
<div className="flex items-center space-x-1">
|
||||
<Tooltip title={t('storage.tips.title')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CircleHelpIcon size={14} />}
|
||||
onClick={() => setIsTipsOpen(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{canWrite && addMedia.actions}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{images.length === 0 ? (
|
||||
<div className="flex flex-col space-y-1 px-2 py-1">
|
||||
<span className="text-xs text-neutral-500">{t('storage.empty')}</span>
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t('storage.add.copyHint')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
renderRows(images)
|
||||
)}
|
||||
</MediaGroup>
|
||||
</>
|
||||
<MediaGroup title={t('storage.groups.images')}>
|
||||
{images.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-neutral-600 px-3 py-3 text-center text-xs text-neutral-200">
|
||||
{t('storage.empty')}
|
||||
</div>
|
||||
) : (
|
||||
renderRows(images)
|
||||
)}
|
||||
</MediaGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col">{browser.content}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{filesReadOnlyHint}
|
||||
{isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center text-neutral-400">
|
||||
<LoaderCircleIcon size={18} className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
browser.content
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function renderRows(items: api.MediaItem[]) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1.5">
|
||||
{items.map((item) => {
|
||||
const key = `${item.type}:${item.rel}`;
|
||||
const isBusy = busy === key;
|
||||
// A CD-ROM mode only carries an optical image; the button says why it
|
||||
// cannot be used on the others.
|
||||
const wrongMode = mountAsCdrom && !api.isOpticalImage(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={clsx(
|
||||
'group flex cursor-pointer items-center justify-between rounded px-2 py-2 text-sm hover:bg-neutral-800/70',
|
||||
selected === key && 'bg-neutral-800/70',
|
||||
item.attached && 'text-blue-500'
|
||||
)}
|
||||
onClick={() => {
|
||||
// The listing must not ask the new medium for the old path.
|
||||
setBrowsePath('/');
|
||||
setSelected(key);
|
||||
}}
|
||||
className="group flex items-center gap-3 rounded-lg border border-solid border-neutral-700 px-3 py-2.5 text-sm transition-colors"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center space-x-2">
|
||||
<MediaIcon item={item} className="shrink-0" />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate" title={item.name}>
|
||||
{item.type === 'partition' ? t('storage.typePartition') : item.name}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{item.type === 'partition' ? t('storage.volumeLabel') : formatSize(item.size)}
|
||||
{item.fs && item.fs !== 'unknown' ? ` · ${item.fs}` : ''}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-9 w-9 shrink-0 items-center justify-center',
|
||||
item.attached ? 'text-blue-400' : 'text-neutral-300'
|
||||
)}
|
||||
>
|
||||
<MediaIcon item={item} size={17} />
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-neutral-100" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
{item.attached && (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-neutral-300">
|
||||
{formatSize(item.size)}
|
||||
{item.fs && item.fs !== 'unknown' ? ` · ${item.fs}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex shrink-0 items-center space-x-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className="flex shrink-0 items-center space-x-1"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{canAttach &&
|
||||
(item.attached ? (
|
||||
<Tooltip title={t('storage.eject')}>
|
||||
<Button
|
||||
type="text"
|
||||
type="default"
|
||||
size="small"
|
||||
loading={busy === 'eject'}
|
||||
icon={<UnplugIcon size={14} />}
|
||||
onClick={() => void eject()}
|
||||
/>
|
||||
@@ -782,54 +800,51 @@ export const Storage = () => {
|
||||
) : (
|
||||
<Tooltip
|
||||
title={
|
||||
mountAsCdrom && !api.isOpticalImage(item)
|
||||
? t('storage.add.cdromOnlyIso')
|
||||
: t('storage.mount')
|
||||
state.state === 'image'
|
||||
? t('storage.add.blocked')
|
||||
: wrongMode
|
||||
? t('storage.add.cdromOnlyIso')
|
||||
: t('storage.mount')
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
type="default"
|
||||
size="small"
|
||||
loading={isBusy}
|
||||
disabled={mountAsCdrom && !api.isOpticalImage(item)}
|
||||
disabled={busy !== '' || state.state === 'image' || wrongMode}
|
||||
icon={<PlugIcon size={14} />}
|
||||
onClick={() => void mount(item, mountAsCdrom)}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
{item.type === 'image' && (
|
||||
<>
|
||||
<Tooltip
|
||||
title={item.attached ? t('storage.add.blocked') : t('storage.downloadImage')}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
disabled={item.attached}
|
||||
icon={<DownloadIcon size={14} />}
|
||||
onClick={() => void downloadImage(item)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{canWrite && (
|
||||
<Tooltip
|
||||
title={
|
||||
dataOwned || item.attached
|
||||
? t('storage.add.blocked')
|
||||
: t('storage.delete')
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
disabled={dataOwned || item.attached}
|
||||
icon={<Trash2Icon size={14} />}
|
||||
onClick={() => confirmDelete(item)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
<Tooltip
|
||||
title={item.attached ? t('storage.add.blocked') : t('storage.downloadImage')}
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
disabled={item.attached}
|
||||
icon={<DownloadIcon size={14} />}
|
||||
onClick={() => void downloadImage(item)}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{canWrite && (
|
||||
<Tooltip
|
||||
title={
|
||||
dataOwned || item.attached ? t('storage.add.blocked') : t('storage.delete')
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
danger
|
||||
disabled={dataOwned || item.attached}
|
||||
icon={<Trash2Icon size={14} />}
|
||||
onClick={() => confirmDelete(item)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -839,3 +854,63 @@ export const Storage = () => {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// One surface, two faces: the desktop menu mounts this inside the disk-mount
|
||||
// modal ('mount') and the file-manager modal ('files'), and the standalone
|
||||
// storage page switches between them.
|
||||
export const StorageView = ({ view, active, headerExtra, fullPage }: StorageViewProps) => {
|
||||
const { token } = theme.useToken();
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={{ token: { colorBgContainer: token.colorBgElevated } }}>
|
||||
<App className={clsx('h-full min-h-0', view === 'mount' && !fullPage && 'h-auto')}>
|
||||
<StorageContent view={view} active={active} headerExtra={headerExtra} fullPage={fullPage} />
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// The standalone page. Deep links and accounts that may only read the library
|
||||
// land here; the desktop menu opens the same two views in modals.
|
||||
export const Storage = () => {
|
||||
const { t } = useTranslation();
|
||||
const [view, setView] = useState<'mount' | 'files'>('mount');
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-neutral-950 text-neutral-200">
|
||||
<Head title={t('head.storage')} />
|
||||
|
||||
<StorageView
|
||||
view={view}
|
||||
active
|
||||
fullPage
|
||||
headerExtra={
|
||||
<Segmented
|
||||
value={view}
|
||||
onChange={(value) => setView(value as 'mount' | 'files')}
|
||||
options={[
|
||||
{
|
||||
value: 'mount',
|
||||
label: (
|
||||
<span className="flex items-center gap-1.5 px-1">
|
||||
<HardDriveIcon size={14} />
|
||||
<span>{t('storage.cards.mount')}</span>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'files',
|
||||
label: (
|
||||
<span className="flex items-center gap-1.5 px-1">
|
||||
<FolderSyncIcon size={14} />
|
||||
<span>{t('storage.cards.share')}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Form, Input, Modal, Tooltip } from 'antd';
|
||||
import { Form, Input, Modal } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { LinkIcon, UploadIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as downloadApi from '@/api/download.ts';
|
||||
@@ -10,9 +9,9 @@ import { formatSize } from '@/lib/format.ts';
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
|
||||
type Props = {
|
||||
// True while the target machine owns the removable disk: images can still be
|
||||
// listed and browsed, but nothing may be written onto it.
|
||||
dataOwned: boolean;
|
||||
// True while writing onto the data partition is impossible: the target
|
||||
// machine owns it, or the mount is still read-only.
|
||||
writeBlocked: boolean;
|
||||
onChanged: () => void;
|
||||
onError: (message: string, description?: string) => void;
|
||||
};
|
||||
@@ -53,7 +52,7 @@ function urlBaseName(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function useAddMedia({ dataOwned, onChanged, onError }: Props) {
|
||||
export function useAddMedia({ writeBlocked, onChanged, onError }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isUrlOpen, setIsUrlOpen] = useState(false);
|
||||
@@ -334,39 +333,17 @@ export function useAddMedia({ dataOwned, onChanged, onError }: Props) {
|
||||
const dropProps = {
|
||||
onDragOver: (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
if (!dataOwned) setIsDragging(true);
|
||||
if (!writeBlocked) setIsDragging(true);
|
||||
},
|
||||
onDragLeave: () => setIsDragging(false),
|
||||
onDrop: (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (dataOwned) return;
|
||||
if (writeBlocked) return;
|
||||
setPendingFile(event.dataTransfer?.files?.[0] ?? null);
|
||||
}
|
||||
};
|
||||
|
||||
// The buttons belong in the image group's title row.
|
||||
const actions = (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Tooltip title={dataOwned ? t('storage.add.blocked') : t('storage.add.upload')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UploadIcon size={14} />}
|
||||
disabled={dataOwned}
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={dataOwned ? t('storage.add.blocked') : t('storage.add.fromUrl')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LinkIcon size={14} />}
|
||||
disabled={dataOwned}
|
||||
onClick={() => setIsUrlOpen(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
const overlays = (
|
||||
<>
|
||||
<input
|
||||
@@ -457,5 +434,15 @@ export function useAddMedia({ dataOwned, onChanged, onError }: Props) {
|
||||
</>
|
||||
);
|
||||
|
||||
return { actions, transfer, cancelTransfer: cancel, dismissTransfer: dismiss, overlays };
|
||||
return {
|
||||
openUpload: () => setIsUploadOpen(true),
|
||||
openUrl: () => setIsUrlOpen(true),
|
||||
// Adding an image writes onto the data partition, which the target
|
||||
// machine owns while it is attached.
|
||||
addDisabled: writeBlocked,
|
||||
transfer,
|
||||
cancelTransfer: cancel,
|
||||
dismissTransfer: dismiss,
|
||||
overlays
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user