mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 01:49:00 -06:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8553cf40b | ||
|
|
bb7194eb6c | ||
|
|
44c420e28b | ||
|
|
4b54fc4768 |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -1,3 +1,38 @@
|
||||
## 2.5.4 (2026-11-10)
|
||||
|
||||
### Features
|
||||
|
||||
* Reworked the storage page around the NanoKVM Go file manager: the storage menu is now a two-card picker, "Disk mount" and "File sharing", that opens dedicated windows, and file sharing became a real file manager for the shared folder with list and icon views (remembered per browser), sorting by name, size, type and modified time, search in the current folder, breadcrumbs, inline folder expansion, new folder, download, delete, and uploads from the picker or by drag and drop, including folder upload with nested directories (thanks to [@liangzhuohua](https://github.com/liangzhuohua))
|
||||
* Kept the two storage windows openable while the virtual disk is switched off, and let a disabled write action name the steps that unlock it: eject the virtual USB drive on the target machine and turn off the Virtual Disk switch in Settings > Device
|
||||
* Removed the manual resolution setting: the video now follows the HDMI input resolution automatically, and the pointer and input-region code use the size of the frame the browser is actually showing
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed an application update leaving the page black or answering 404: service operations are serialized with an init-script lock, the migration finishes before services start, the startup script is installed atomically, recursive restarts are gone and the migration marker is kept until every required change succeeds
|
||||
* Fixed video recovery after an application update leaving the encoder and VPSS in a broken state: video ownership is claimed before orphan VENC/VPSS resources are cleared, and the recovery SYS cleanup always runs to completion
|
||||
* Fixed an updated web interface being hidden by a cached document: `index.html` now revalidates on every load and content-hashed assets are cached as immutable only while the file exists, so a stale document or a cached 404 is never pinned in the browser
|
||||
* Fixed keyboard input being stolen from text fields: forwarding now skips events aimed at an input, textarea, select or contenteditable element and releases held keys when focus moves into one, so typing and pasting in the new dialogs works
|
||||
* Fixed `/data` staying read-only while no medium was attached after a failed remount or an exFAT error; the media state poll now heals it and writable opens and transfers retry before failing
|
||||
* Fixed the disk mount window reporting transfers it did not start by keeping the media job in the file manager
|
||||
|
||||
### Performance
|
||||
|
||||
* Reworked H.264 and MJPEG capture on the Sophgo SDK: each codec gets its own capture session and lock with encoder backpressure, frame ownership is explicit, capture stops as soon as the last viewer leaves, and the bundled video libraries were rebuilt
|
||||
|
||||
### UI Improvements
|
||||
|
||||
* Made a menu picker that opens its own window close without collapsing the menu bar
|
||||
|
||||
### Localization
|
||||
|
||||
* Translated the new storage page and file manager strings for all supported locales
|
||||
|
||||
### Chores
|
||||
|
||||
* Added a `POST /api/storage/media/files/mkdir` endpoint for recreating a folder tree during upload, with the same permission model as upload and delete, path and symlinked-parent validation, and a distinct error when the name is already taken by a file
|
||||
* Rebuilt the bundled `libkvm.so` and `libkvm_mmf.so` against the Sophgo SDK and refreshed the build and deployment instructions
|
||||
* Added unit tests for media file creation, read-only `/data` healing and the static file cache headers
|
||||
|
||||
## 2.5.3 (2026-11-03)
|
||||
|
||||
### Features
|
||||
|
||||
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