mirror of https://github.com/jetkvm/kvm.git
Merge 82562b77c4 into d24ce1c76f
This commit is contained in:
commit
a1997ba069
|
|
@ -0,0 +1,88 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# .devcontainer/install_audio_deps.sh
|
||||||
|
# Build ALSA and Opus static libs for ARM in /opt/jetkvm-audio-libs
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Sudo wrapper function
|
||||||
|
function use_sudo() {
|
||||||
|
if [ "$UID" -eq 0 ] || [ -z "$(which sudo 2>/dev/null)" ]; then
|
||||||
|
"$@"
|
||||||
|
else
|
||||||
|
sudo -E "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Accept version parameters or use defaults
|
||||||
|
ALSA_VERSION="${1:-1.2.14}"
|
||||||
|
OPUS_VERSION="${2:-1.5.2}"
|
||||||
|
SPEEXDSP_VERSION="${3:-1.2.1}"
|
||||||
|
|
||||||
|
AUDIO_LIBS_DIR="/opt/jetkvm-audio-libs"
|
||||||
|
BUILDKIT_PATH="/opt/jetkvm-native-buildkit"
|
||||||
|
BUILDKIT_FLAVOR="arm-rockchip830-linux-uclibcgnueabihf"
|
||||||
|
CROSS_PREFIX="$BUILDKIT_PATH/bin/$BUILDKIT_FLAVOR"
|
||||||
|
|
||||||
|
# Create directory with proper permissions
|
||||||
|
use_sudo mkdir -p "$AUDIO_LIBS_DIR"
|
||||||
|
use_sudo chmod 777 "$AUDIO_LIBS_DIR"
|
||||||
|
cd "$AUDIO_LIBS_DIR"
|
||||||
|
|
||||||
|
# Download sources
|
||||||
|
[ -f alsa-lib-${ALSA_VERSION}.tar.bz2 ] || wget -N https://www.alsa-project.org/files/pub/lib/alsa-lib-${ALSA_VERSION}.tar.bz2
|
||||||
|
[ -f opus-${OPUS_VERSION}.tar.gz ] || wget -N https://downloads.xiph.org/releases/opus/opus-${OPUS_VERSION}.tar.gz
|
||||||
|
[ -f speexdsp-${SPEEXDSP_VERSION}.tar.gz ] || wget -N https://ftp.osuosl.org/pub/xiph/releases/speex/speexdsp-${SPEEXDSP_VERSION}.tar.gz
|
||||||
|
|
||||||
|
# Extract
|
||||||
|
[ -d alsa-lib-${ALSA_VERSION} ] || tar xf alsa-lib-${ALSA_VERSION}.tar.bz2
|
||||||
|
[ -d opus-${OPUS_VERSION} ] || tar xf opus-${OPUS_VERSION}.tar.gz
|
||||||
|
[ -d speexdsp-${SPEEXDSP_VERSION} ] || tar xf speexdsp-${SPEEXDSP_VERSION}.tar.gz
|
||||||
|
|
||||||
|
# ARM Cortex-A7 optimization flags with NEON support
|
||||||
|
OPTIM_CFLAGS="-O2 -mfpu=neon -mtune=cortex-a7 -mfloat-abi=hard -DNDEBUG"
|
||||||
|
|
||||||
|
export CC="${CROSS_PREFIX}-gcc"
|
||||||
|
export CFLAGS="$OPTIM_CFLAGS"
|
||||||
|
export CXXFLAGS="$OPTIM_CFLAGS"
|
||||||
|
|
||||||
|
# Build ALSA
|
||||||
|
cd alsa-lib-${ALSA_VERSION}
|
||||||
|
if [ ! -f .built ]; then
|
||||||
|
chown -R $(whoami):$(whoami) .
|
||||||
|
# Minimal ALSA configuration for direct hw: device access with SpeexDSP resampling
|
||||||
|
CFLAGS="$OPTIM_CFLAGS" ./configure --host $BUILDKIT_FLAVOR \
|
||||||
|
--enable-static=yes --enable-shared=no \
|
||||||
|
--with-pcm-plugins=linear,copy \
|
||||||
|
--disable-seq --disable-rawmidi --disable-ucm \
|
||||||
|
--disable-python --disable-old-symbols \
|
||||||
|
--disable-topology --disable-hwdep --disable-mixer \
|
||||||
|
--disable-alisp --disable-aload --disable-resmgr
|
||||||
|
make -j$(nproc)
|
||||||
|
touch .built
|
||||||
|
fi
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Build Opus
|
||||||
|
cd opus-${OPUS_VERSION}
|
||||||
|
if [ ! -f .built ]; then
|
||||||
|
chown -R $(whoami):$(whoami) .
|
||||||
|
CFLAGS="$OPTIM_CFLAGS" ./configure --host $BUILDKIT_FLAVOR --enable-static=yes --enable-shared=no --enable-fixed-point
|
||||||
|
make -j$(nproc)
|
||||||
|
touch .built
|
||||||
|
fi
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Build SpeexDSP
|
||||||
|
cd speexdsp-${SPEEXDSP_VERSION}
|
||||||
|
if [ ! -f .built ]; then
|
||||||
|
chown -R $(whoami):$(whoami) .
|
||||||
|
# NEON-optimized high-quality resampler
|
||||||
|
CFLAGS="$OPTIM_CFLAGS" ./configure --host $BUILDKIT_FLAVOR \
|
||||||
|
--enable-static=yes --enable-shared=no \
|
||||||
|
--enable-neon \
|
||||||
|
--disable-examples
|
||||||
|
make -j$(nproc)
|
||||||
|
touch .built
|
||||||
|
fi
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo "ALSA, Opus, and SpeexDSP built in $AUDIO_LIBS_DIR"
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
# JetKVM Copilot Instructions
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
JetKVM is a high-performance KVM-over-IP solution with **bidirectional architecture**: a Go backend running on ARM hardware and a React/TypeScript frontend served by the device. The system provides real-time video streaming, HID input, virtual media mounting, and **2-way audio support** via WebRTC.
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
- **Backend**: Go application (`main.go`, `web.go`, `webrtc.go`) handling WebRTC, HTTP APIs, hardware interfaces
|
||||||
|
- **Frontend**: React/TypeScript SPA (`ui/src/`) with Vite build system, served as embedded static files
|
||||||
|
- **Hardware Layer**: CGO bridge (`internal/native/`) to ARM SoC for video capture, USB gadget, display control
|
||||||
|
- **Audio System**: In-process CGO implementation (`internal/audio/`) with ALSA/Opus for USB Audio Gadget streaming
|
||||||
|
- **USB Gadget**: Configurable emulation (`internal/usbgadget/`) - keyboard, mouse, mass storage, **audio device**
|
||||||
|
|
||||||
|
## Development Workflows
|
||||||
|
|
||||||
|
### Quick Development Commands
|
||||||
|
```bash
|
||||||
|
# Full development deployment (most common)
|
||||||
|
./dev_deploy.sh -r <DEVICE_IP>
|
||||||
|
|
||||||
|
# UI-only changes (faster iteration)
|
||||||
|
cd ui && ./dev_device.sh <DEVICE_IP>
|
||||||
|
|
||||||
|
# Backend-only changes (skip UI build)
|
||||||
|
./dev_deploy.sh -r <DEVICE_IP> --skip-ui-build
|
||||||
|
|
||||||
|
# Build for release
|
||||||
|
make build_release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Critical Build Dependencies
|
||||||
|
- **Audio libraries**: `make build_audio_deps` builds ALSA and Opus static libs for ARM
|
||||||
|
- **Native CGO**: `make build_native` compiles hardware interface layer
|
||||||
|
- **Cross-compilation**: ARM7 with buildkit toolchain at `/opt/jetkvm-native-buildkit`
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Run Go tests on device
|
||||||
|
./dev_deploy.sh -r <DEVICE_IP> --run-go-tests
|
||||||
|
|
||||||
|
# UI linting
|
||||||
|
cd ui && npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project-Specific Patterns
|
||||||
|
|
||||||
|
### Configuration Management
|
||||||
|
- **Single config file**: `/userdata/kvm_config.json` persisted across updates
|
||||||
|
- **Config struct**: `config.go` with JSON tags, atomic operations for thread safety
|
||||||
|
- **Migration pattern**: Use `ensureConfigLoaded()` before config access
|
||||||
|
|
||||||
|
### WebRTC Architecture
|
||||||
|
- **Session management**: `webrtc.go` handles peer connections with connection pooling
|
||||||
|
- **Media tracks**: Separate video and **stereo audio tracks** in independent MediaStreams
|
||||||
|
- **Data channels**: RPC (ordered), HID (unordered), upload, terminal, serial channels
|
||||||
|
- **SDP munging**: Frontend modifies SDP for Opus stereo parameters (`OPUS_STEREO_PARAMS`)
|
||||||
|
|
||||||
|
### Audio Implementation Details
|
||||||
|
- **USB Audio Gadget**: UAC1 device presenting as stereo speakers + microphone
|
||||||
|
- **Bidirectional streaming**: Output (device→browser) and input (browser→device)
|
||||||
|
- **CGO integration**: `internal/audio/cgo_source.go` bridges Go↔C for ALSA operations
|
||||||
|
- **Frame-based processing**: 20ms Opus frames (960 samples @ 48kHz) for low latency
|
||||||
|
|
||||||
|
### Frontend State Management
|
||||||
|
- **Zustand stores**: `hooks/stores.ts` - RTC, UI, Settings, Video state with persistence
|
||||||
|
- **JSON-RPC communication**: `useJsonRpc` hook for backend API calls
|
||||||
|
- **Localization**: Paraglide.js with compile-time validation - all UI strings use `m.key()` pattern
|
||||||
|
|
||||||
|
### USB Gadget Configuration
|
||||||
|
- **Configfs-based**: Dynamic reconfiguration via `/sys/kernel/config/usb_gadget/`
|
||||||
|
- **Transaction pattern**: `WithTransaction()` ensures atomic gadget changes
|
||||||
|
- **Device composition**: Keyboard + mouse + mass storage + **audio** with individual toggle support
|
||||||
|
|
||||||
|
## Integration Patterns
|
||||||
|
|
||||||
|
### Backend API Structure
|
||||||
|
- **JSON-RPC over WebSocket**: Primary communication via data channels
|
||||||
|
- **HTTP endpoints**: `/api/*` for REST operations, `/static/*` for UI assets
|
||||||
|
- **Configuration endpoints**: `rpc*` functions in `jsonrpc.go` with parameter validation
|
||||||
|
|
||||||
|
### Cross-Component Communication
|
||||||
|
- **Video pipeline**: `native.go` → WebRTC track → browser via H.264 streaming
|
||||||
|
- **HID input**: Browser → WebRTC data channel → `internal/usbgadget/hid_*.go` → Linux gadget
|
||||||
|
- **Audio pipeline**: ALSA capture → Opus encode → WebRTC → browser speakers (and reverse for mic)
|
||||||
|
- **Virtual media**: HTTP upload → storage → NBD server → USB mass storage gadget
|
||||||
|
|
||||||
|
### Error Handling Patterns
|
||||||
|
- **Graceful degradation**: Audio/video failures don't crash core functionality
|
||||||
|
- **Session isolation**: Per-connection logging with `connectionID` context
|
||||||
|
- **Recovery mechanisms**: USB gadget timeouts, audio restart on device changes
|
||||||
|
|
||||||
|
## Key Files for Common Tasks
|
||||||
|
|
||||||
|
### Adding new RPC endpoints
|
||||||
|
1. Add handler function in `jsonrpc.go`
|
||||||
|
2. Register in `rpcHandlers` map
|
||||||
|
3. Add frontend hook in `useJsonRpc`
|
||||||
|
|
||||||
|
### USB device modifications
|
||||||
|
- `internal/usbgadget/config.go` - gadget composition and attributes
|
||||||
|
- `internal/usbgadget/hid_*.go` - HID device implementations
|
||||||
|
|
||||||
|
### Audio feature development
|
||||||
|
- `audio.go` - high-level audio management and session integration
|
||||||
|
- `internal/audio/` - CGO sources, relays, and C audio processing
|
||||||
|
|
||||||
|
### Frontend feature development
|
||||||
|
- `ui/src/routes/` - page components
|
||||||
|
- `ui/src/components/` - reusable UI components
|
||||||
|
- `ui/localization/messages/en.json` - localizable strings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This codebase uses advanced patterns like CGO, USB gadgets, and real-time media streaming. Focus on understanding the session lifecycle and cross-compilation requirements for effective development.*
|
||||||
|
|
@ -15,3 +15,13 @@ node_modules
|
||||||
#internal/native/lib
|
#internal/native/lib
|
||||||
|
|
||||||
ui/reports
|
ui/reports
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
app.log
|
||||||
|
deploy.log
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.devcontainer.json
|
||||||
|
utilities/
|
||||||
|
core
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,21 @@ If you're using Windows, we strongly recommend using **WSL (Windows Subsystem fo
|
||||||
|
|
||||||
This ensures compatibility with shell scripts and build tools used in the project.
|
This ensures compatibility with shell scripts and build tools used in the project.
|
||||||
|
|
||||||
|
#### Using DevPod
|
||||||
|
|
||||||
|
**For Apple Silicon (M1/M2/M3/M4) Mac users:** You must set the Docker platform to `linux/amd64` before starting the DevPod container, as the JetKVM build system requires x86_64 architecture:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DOCKER_DEFAULT_PLATFORM=linux/amd64
|
||||||
|
devpod up . --id kvm --provider docker --devcontainer-path .devcontainer/docker/devcontainer.json
|
||||||
|
```
|
||||||
|
|
||||||
|
After the container starts, you'll need to manually install build dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .devcontainer/install-deps.sh
|
||||||
|
```
|
||||||
|
|
||||||
### Project Setup
|
### Project Setup
|
||||||
|
|
||||||
1. **Clone the repository:**
|
1. **Clone the repository:**
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ ENV GOPATH=/go
|
||||||
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
|
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
|
||||||
|
|
||||||
COPY install-deps.sh /install-deps.sh
|
COPY install-deps.sh /install-deps.sh
|
||||||
|
COPY install_audio_deps.sh /install_audio_deps.sh
|
||||||
|
|
||||||
RUN /install-deps.sh
|
RUN /install-deps.sh
|
||||||
|
|
||||||
# Create build directory
|
# Create build directory
|
||||||
|
|
|
||||||
32
Makefile
32
Makefile
|
|
@ -47,6 +47,10 @@ BIN_DIR := $(shell pwd)/bin
|
||||||
|
|
||||||
TEST_DIRS := $(shell find . -name "*_test.go" -type f -exec dirname {} \; | sort -u)
|
TEST_DIRS := $(shell find . -name "*_test.go" -type f -exec dirname {} \; | sort -u)
|
||||||
|
|
||||||
|
# Build ALSA and Opus static libs for ARM in /opt/jetkvm-audio-libs
|
||||||
|
build_audio_deps:
|
||||||
|
bash .devcontainer/install_audio_deps.sh
|
||||||
|
|
||||||
build_native:
|
build_native:
|
||||||
@if [ "$(SKIP_NATIVE_IF_EXISTS)" = "1" ] && [ -f "internal/native/cgo/lib/libjknative.a" ]; then \
|
@if [ "$(SKIP_NATIVE_IF_EXISTS)" = "1" ] && [ -f "internal/native/cgo/lib/libjknative.a" ]; then \
|
||||||
echo "libjknative.a already exists, skipping native build..."; \
|
echo "libjknative.a already exists, skipping native build..."; \
|
||||||
|
|
@ -137,3 +141,31 @@ release:
|
||||||
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
|
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
|
||||||
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION)/jetkvm_app
|
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION)/jetkvm_app
|
||||||
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256
|
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256
|
||||||
|
# Run golangci-lint locally with the same configuration as CI
|
||||||
|
lint-go: build_audio_deps
|
||||||
|
@echo "Running golangci-lint..."
|
||||||
|
@mkdir -p static && touch static/.gitkeep
|
||||||
|
golangci-lint run --verbose
|
||||||
|
|
||||||
|
# Run both Go and UI linting with auto-fix
|
||||||
|
lint-fix: lint-go-fix lint-ui-fix
|
||||||
|
@echo "All linting with auto-fix completed successfully!"
|
||||||
|
|
||||||
|
# Run golangci-lint with auto-fix
|
||||||
|
lint-go-fix: build_audio_deps
|
||||||
|
@echo "Running golangci-lint with auto-fix..."
|
||||||
|
@mkdir -p static && touch static/.gitkeep
|
||||||
|
golangci-lint run --fix --verbose
|
||||||
|
|
||||||
|
# Run UI linting locally (mirrors GitHub workflow ui-lint.yml)
|
||||||
|
lint-ui:
|
||||||
|
@echo "Running UI lint..."
|
||||||
|
@cd ui && npm ci && npm run lint
|
||||||
|
|
||||||
|
# Run UI linting with auto-fix
|
||||||
|
lint-ui-fix:
|
||||||
|
@echo "Running UI lint with auto-fix..."
|
||||||
|
@cd ui && npm ci && npm run lint:fix
|
||||||
|
|
||||||
|
# Legacy alias for UI linting (for backward compatibility)
|
||||||
|
ui-lint: lint-ui
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,466 @@
|
||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/audio"
|
||||||
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
audioMutex sync.Mutex
|
||||||
|
inputSourceMutex sync.Mutex // Prevents concurrent WebRTC packets from racing during lazy connect + write
|
||||||
|
outputSource atomic.Pointer[audio.AudioSource]
|
||||||
|
inputSource atomic.Pointer[audio.AudioSource]
|
||||||
|
outputRelay atomic.Pointer[audio.OutputRelay]
|
||||||
|
inputRelay atomic.Pointer[audio.InputRelay]
|
||||||
|
audioInitialized bool
|
||||||
|
activeConnections atomic.Int32
|
||||||
|
audioLogger zerolog.Logger
|
||||||
|
currentAudioTrack *webrtc.TrackLocalStaticSample
|
||||||
|
currentInputTrack atomic.Pointer[string]
|
||||||
|
audioOutputEnabled atomic.Bool
|
||||||
|
audioInputEnabled atomic.Bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func getAlsaDevice(source string) string {
|
||||||
|
if source == "hdmi" {
|
||||||
|
return "hw:0,0" // TC358743 HDMI audio
|
||||||
|
}
|
||||||
|
return "hw:1,0" // USB Audio Gadget
|
||||||
|
}
|
||||||
|
|
||||||
|
func initAudio() {
|
||||||
|
audioLogger = logging.GetDefaultLogger().With().Str("component", "audio-manager").Logger()
|
||||||
|
|
||||||
|
ensureConfigLoaded()
|
||||||
|
audioOutputEnabled.Store(config.AudioOutputEnabled)
|
||||||
|
audioInputEnabled.Store(config.AudioInputAutoEnable)
|
||||||
|
|
||||||
|
audioLogger.Debug().Msg("Audio subsystem initialized")
|
||||||
|
audioInitialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAudioConfig() audio.AudioConfig {
|
||||||
|
cfg := audio.DefaultAudioConfig()
|
||||||
|
|
||||||
|
if config.AudioBitrate >= 64 && config.AudioBitrate <= 256 {
|
||||||
|
cfg.Bitrate = uint16(config.AudioBitrate)
|
||||||
|
} else if config.AudioBitrate != 0 {
|
||||||
|
audioLogger.Warn().Int("bitrate", config.AudioBitrate).Msg("Invalid audio bitrate, using default")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.AudioComplexity >= 0 && config.AudioComplexity <= 10 {
|
||||||
|
cfg.Complexity = uint8(config.AudioComplexity)
|
||||||
|
} else if config.AudioComplexity != 0 {
|
||||||
|
audioLogger.Warn().Int("complexity", config.AudioComplexity).Msg("Invalid audio complexity, using default")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.AudioBufferPeriods >= 2 && config.AudioBufferPeriods <= 24 {
|
||||||
|
cfg.BufferPeriods = uint8(config.AudioBufferPeriods)
|
||||||
|
} else if config.AudioBufferPeriods != 0 {
|
||||||
|
audioLogger.Warn().Int("buffer_periods", config.AudioBufferPeriods).Msg("Invalid buffer periods, using default")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.AudioPacketLossPerc >= 0 && config.AudioPacketLossPerc <= 100 {
|
||||||
|
cfg.PacketLossPerc = uint8(config.AudioPacketLossPerc)
|
||||||
|
} else if config.AudioPacketLossPerc != 0 {
|
||||||
|
audioLogger.Warn().Int("packet_loss_perc", config.AudioPacketLossPerc).Msg("Invalid packet loss percentage, using default")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.DTXEnabled = config.AudioDTXEnabled
|
||||||
|
cfg.FECEnabled = config.AudioFECEnabled
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAudio() error {
|
||||||
|
audioMutex.Lock()
|
||||||
|
defer audioMutex.Unlock()
|
||||||
|
|
||||||
|
if !audioInitialized {
|
||||||
|
audioLogger.Warn().Msg("Audio not initialized, skipping start")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if activeConnections.Load() <= 0 {
|
||||||
|
audioLogger.Debug().Msg("No active connections, skipping audio start")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureConfigLoaded()
|
||||||
|
|
||||||
|
var outputErr, inputErr error
|
||||||
|
|
||||||
|
// Start output audio if enabled and track is available
|
||||||
|
if audioOutputEnabled.Load() && currentAudioTrack != nil {
|
||||||
|
outputErr = startOutputAudioUnderMutex(getAlsaDevice(config.AudioOutputSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start input audio if enabled and USB audio device is configured
|
||||||
|
if audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio {
|
||||||
|
inputErr = startInputAudioUnderMutex(getAlsaDevice("usb"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return combined errors if any
|
||||||
|
if outputErr != nil && inputErr != nil {
|
||||||
|
return fmt.Errorf("audio start failed - output: %w, input: %v", outputErr, inputErr)
|
||||||
|
}
|
||||||
|
return firstError(outputErr, inputErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstError(errs ...error) error {
|
||||||
|
for _, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func startOutputAudioUnderMutex(alsaOutputDevice string) error {
|
||||||
|
oldRelay := outputRelay.Swap(nil)
|
||||||
|
oldSource := outputSource.Swap(nil)
|
||||||
|
|
||||||
|
if oldRelay != nil {
|
||||||
|
oldRelay.Stop()
|
||||||
|
}
|
||||||
|
if oldSource != nil {
|
||||||
|
(*oldSource).Disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
newSource := audio.NewCgoOutputSource(alsaOutputDevice, getAudioConfig())
|
||||||
|
newRelay := audio.NewOutputRelay(&newSource, currentAudioTrack)
|
||||||
|
|
||||||
|
if err := newRelay.Start(); err != nil {
|
||||||
|
audioLogger.Error().Err(err).Str("alsaOutputDevice", alsaOutputDevice).Msg("Failed to start audio output relay")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
outputSource.Swap(&newSource)
|
||||||
|
outputRelay.Swap(newRelay)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func startInputAudioUnderMutex(alsaPlaybackDevice string) error {
|
||||||
|
oldRelay := inputRelay.Swap(nil)
|
||||||
|
oldSource := inputSource.Swap(nil)
|
||||||
|
|
||||||
|
if oldRelay != nil {
|
||||||
|
oldRelay.Stop()
|
||||||
|
}
|
||||||
|
if oldSource != nil {
|
||||||
|
(*oldSource).Disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
newSource := audio.NewCgoInputSource(alsaPlaybackDevice, getAudioConfig())
|
||||||
|
newRelay := audio.NewInputRelay()
|
||||||
|
|
||||||
|
if err := newRelay.Start(); err != nil {
|
||||||
|
audioLogger.Error().Err(err).Str("alsaPlaybackDevice", alsaPlaybackDevice).Msg("Failed to start input relay")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
inputSource.Swap(&newSource)
|
||||||
|
inputRelay.Swap(newRelay)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopOutputAudio() {
|
||||||
|
audioMutex.Lock()
|
||||||
|
oldRelay := outputRelay.Swap(nil)
|
||||||
|
oldSource := outputSource.Swap(nil)
|
||||||
|
audioMutex.Unlock()
|
||||||
|
|
||||||
|
if oldRelay != nil {
|
||||||
|
oldRelay.Stop()
|
||||||
|
}
|
||||||
|
if oldSource != nil {
|
||||||
|
(*oldSource).Disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopInputAudio() {
|
||||||
|
audioMutex.Lock()
|
||||||
|
oldRelay := inputRelay.Swap(nil)
|
||||||
|
oldSource := inputSource.Swap(nil)
|
||||||
|
audioMutex.Unlock()
|
||||||
|
|
||||||
|
if oldRelay != nil {
|
||||||
|
oldRelay.Stop()
|
||||||
|
}
|
||||||
|
if oldSource != nil {
|
||||||
|
(*oldSource).Disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAudio() {
|
||||||
|
stopOutputAudio()
|
||||||
|
stopInputAudio()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onWebRTCConnect() {
|
||||||
|
count := activeConnections.Add(1)
|
||||||
|
if count == 1 {
|
||||||
|
if err := startAudio(); err != nil {
|
||||||
|
audioLogger.Error().Err(err).Msg("Failed to start audio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onWebRTCDisconnect() {
|
||||||
|
count := activeConnections.Add(-1)
|
||||||
|
if count <= 0 {
|
||||||
|
// Stop audio immediately to release HDMI audio device which shares hardware with video device
|
||||||
|
stopAudio()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAudioTrack(audioTrack *webrtc.TrackLocalStaticSample) {
|
||||||
|
audioMutex.Lock()
|
||||||
|
defer audioMutex.Unlock()
|
||||||
|
|
||||||
|
outRelay := outputRelay.Swap(nil)
|
||||||
|
outSource := outputSource.Swap(nil)
|
||||||
|
if outRelay != nil {
|
||||||
|
outRelay.Stop()
|
||||||
|
}
|
||||||
|
if outSource != nil {
|
||||||
|
(*outSource).Disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAudioTrack = audioTrack
|
||||||
|
|
||||||
|
if audioInitialized && activeConnections.Load() > 0 && audioOutputEnabled.Load() && currentAudioTrack != nil {
|
||||||
|
if err := startOutputAudioUnderMutex(getAlsaDevice(config.AudioOutputSource)); err != nil {
|
||||||
|
audioLogger.Error().Err(err).Msg("Failed to start output audio after track change")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPendingInputTrack(track *webrtc.TrackRemote) {
|
||||||
|
trackID := track.ID()
|
||||||
|
currentInputTrack.Store(&trackID)
|
||||||
|
go handleInputTrackForSession(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudioOutputEnabled blocks up to 5 seconds when enabling.
|
||||||
|
// Returns error if audio fails to start within timeout.
|
||||||
|
func SetAudioOutputEnabled(enabled bool) error {
|
||||||
|
if audioOutputEnabled.Swap(enabled) == enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled && activeConnections.Load() > 0 {
|
||||||
|
// Start audio synchronously with timeout to provide immediate feedback
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- startAudio()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
audioLogger.Error().Err(err).Msg("Failed to start output audio after enable")
|
||||||
|
audioOutputEnabled.Store(false) // Revert state on failure
|
||||||
|
return fmt.Errorf("failed to start audio output: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
audioLogger.Error().Msg("Audio output start timed out after 5 seconds")
|
||||||
|
audioOutputEnabled.Store(false) // Revert state on timeout
|
||||||
|
go stopOutputAudio() // Clean up any partial initialization asynchronously
|
||||||
|
return fmt.Errorf("audio output start timed out after 5 seconds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stopOutputAudio()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudioInputEnabled blocks up to 5 seconds when enabling.
|
||||||
|
// Returns error if audio fails to start within timeout.
|
||||||
|
func SetAudioInputEnabled(enabled bool) error {
|
||||||
|
if audioInputEnabled.Swap(enabled) == enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled && activeConnections.Load() > 0 {
|
||||||
|
// Start audio synchronously with timeout to provide immediate feedback
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- startAudio()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
audioLogger.Error().Err(err).Msg("Failed to start input audio after enable")
|
||||||
|
audioInputEnabled.Store(false) // Revert state on failure
|
||||||
|
return fmt.Errorf("failed to start audio input: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
audioLogger.Error().Msg("Audio input start timed out after 5 seconds")
|
||||||
|
audioInputEnabled.Store(false) // Revert state on timeout
|
||||||
|
go stopInputAudio() // Clean up any partial initialization asynchronously
|
||||||
|
return fmt.Errorf("audio input start timed out after 5 seconds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stopInputAudio()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudioOutputSource switches between HDMI and USB audio capture.
|
||||||
|
// Config is saved synchronously, audio restarts asynchronously.
|
||||||
|
func SetAudioOutputSource(source string) error {
|
||||||
|
if source != "hdmi" && source != "usb" {
|
||||||
|
return fmt.Errorf("invalid audio source: %s (must be 'hdmi' or 'usb')", source)
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureConfigLoaded()
|
||||||
|
if config.AudioOutputSource == source {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
config.AudioOutputSource = source
|
||||||
|
|
||||||
|
// Save config synchronously before starting async audio operations
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
audioLogger.Error().Err(err).Msg("Failed to save config after audio source change")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop audio immediately (synchronous to release hardware)
|
||||||
|
stopOutputAudio()
|
||||||
|
|
||||||
|
// Restart audio with timeout
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- startAudio()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
audioLogger.Error().Err(err).Str("source", source).Msg("Failed to start audio after source change")
|
||||||
|
return fmt.Errorf("failed to start audio after source change: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
audioLogger.Error().Str("source", source).Msg("Audio restart timed out after source change")
|
||||||
|
return fmt.Errorf("audio restart timed out after 5 seconds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestartAudioOutput stops and restarts the audio output capture.
|
||||||
|
// Blocks up to 5 seconds waiting for audio to restart.
|
||||||
|
// Returns error if restart fails or times out.
|
||||||
|
func RestartAudioOutput() error {
|
||||||
|
audioMutex.Lock()
|
||||||
|
hasActiveOutput := audioOutputEnabled.Load() && currentAudioTrack != nil && outputSource.Load() != nil
|
||||||
|
audioMutex.Unlock()
|
||||||
|
|
||||||
|
if !hasActiveOutput {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
audioLogger.Info().Msg("Restarting audio output")
|
||||||
|
stopOutputAudio()
|
||||||
|
|
||||||
|
// Restart with timeout
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- startAudio()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
audioLogger.Error().Err(err).Msg("Failed to restart audio output")
|
||||||
|
return fmt.Errorf("failed to restart audio output: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
audioLogger.Error().Msg("Audio output restart timed out")
|
||||||
|
return fmt.Errorf("audio output restart timed out after 5 seconds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleInputTrackForSession(track *webrtc.TrackRemote) {
|
||||||
|
myTrackID := track.ID()
|
||||||
|
|
||||||
|
trackLogger := audioLogger.With().
|
||||||
|
Str("codec", track.Codec().MimeType).
|
||||||
|
Str("track_id", myTrackID).
|
||||||
|
Logger()
|
||||||
|
|
||||||
|
trackLogger.Debug().Msg("starting input track handler")
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Check if we've been superseded by another track
|
||||||
|
currentTrackID := currentInputTrack.Load()
|
||||||
|
if currentTrackID != nil && *currentTrackID != myTrackID {
|
||||||
|
trackLogger.Debug().
|
||||||
|
Str("current_track_id", *currentTrackID).
|
||||||
|
Msg("input track handler exiting - superseded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read RTP packet
|
||||||
|
rtpPacket, _, err := track.ReadRTP()
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
trackLogger.Debug().Msg("input track ended")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trackLogger.Warn().Err(err).Msg("failed to read RTP packet")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip empty payloads
|
||||||
|
if len(rtpPacket.Payload) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if input is disabled
|
||||||
|
if !audioInputEnabled.Load() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the audio packet
|
||||||
|
if err := processInputPacket(rtpPacket.Payload); err != nil {
|
||||||
|
trackLogger.Warn().Err(err).Msg("failed to process audio packet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processInputPacket(opusData []byte) error {
|
||||||
|
inputSourceMutex.Lock()
|
||||||
|
defer inputSourceMutex.Unlock()
|
||||||
|
|
||||||
|
source := inputSource.Load()
|
||||||
|
if source == nil || *source == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy connect on first use
|
||||||
|
if !(*source).IsConnected() {
|
||||||
|
if err := (*source).Connect(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write opus data, disconnect on error
|
||||||
|
if err := (*source).WriteMessage(0, opusData); err != nil {
|
||||||
|
(*source).Disconnect()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
42
config.go
42
config.go
|
|
@ -10,6 +10,7 @@ import (
|
||||||
|
|
||||||
"github.com/jetkvm/kvm/internal/confparser"
|
"github.com/jetkvm/kvm/internal/confparser"
|
||||||
"github.com/jetkvm/kvm/internal/logging"
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
|
"github.com/jetkvm/kvm/internal/native"
|
||||||
"github.com/jetkvm/kvm/internal/network/types"
|
"github.com/jetkvm/kvm/internal/network/types"
|
||||||
"github.com/jetkvm/kvm/internal/usbgadget"
|
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
@ -95,7 +96,7 @@ type Config struct {
|
||||||
IncludePreRelease bool `json:"include_pre_release"`
|
IncludePreRelease bool `json:"include_pre_release"`
|
||||||
HashedPassword string `json:"hashed_password"`
|
HashedPassword string `json:"hashed_password"`
|
||||||
LocalAuthToken string `json:"local_auth_token"`
|
LocalAuthToken string `json:"local_auth_token"`
|
||||||
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
|
LocalAuthMode string `json:"localAuthMode"` // Uses camelCase for backwards compatibility with existing configs
|
||||||
LocalLoopbackOnly bool `json:"local_loopback_only"`
|
LocalLoopbackOnly bool `json:"local_loopback_only"`
|
||||||
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
||||||
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
||||||
|
|
@ -113,6 +114,15 @@ type Config struct {
|
||||||
DefaultLogLevel string `json:"default_log_level"`
|
DefaultLogLevel string `json:"default_log_level"`
|
||||||
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
|
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
|
||||||
VideoQualityFactor float64 `json:"video_quality_factor"`
|
VideoQualityFactor float64 `json:"video_quality_factor"`
|
||||||
|
AudioInputAutoEnable bool `json:"audio_input_auto_enable"`
|
||||||
|
AudioOutputEnabled bool `json:"audio_output_enabled"`
|
||||||
|
AudioOutputSource string `json:"audio_output_source"` // "hdmi" or "usb"
|
||||||
|
AudioBitrate int `json:"audio_bitrate"` // kbps (64-256)
|
||||||
|
AudioComplexity int `json:"audio_complexity"` // 0-10
|
||||||
|
AudioDTXEnabled bool `json:"audio_dtx_enabled"`
|
||||||
|
AudioFECEnabled bool `json:"audio_fec_enabled"`
|
||||||
|
AudioBufferPeriods int `json:"audio_buffer_periods"` // 2-24
|
||||||
|
AudioPacketLossPerc int `json:"audio_packet_loss_perc"` // 0-100
|
||||||
NativeMaxRestart uint `json:"native_max_restart_attempts"`
|
NativeMaxRestart uint `json:"native_max_restart_attempts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,8 +157,8 @@ func (c *Config) SetDisplayRotation(rotation string) error {
|
||||||
|
|
||||||
const configPath = "/userdata/kvm_config.json"
|
const configPath = "/userdata/kvm_config.json"
|
||||||
|
|
||||||
// it's a temporary solution to avoid sharing the same pointer
|
// Default configuration structs used to create independent copies in getDefaultConfig().
|
||||||
// we should migrate to a proper config solution in the future
|
// These are package-level variables to avoid repeated allocations.
|
||||||
var (
|
var (
|
||||||
defaultJigglerConfig = JigglerConfig{
|
defaultJigglerConfig = JigglerConfig{
|
||||||
InactivityLimitSeconds: 60,
|
InactivityLimitSeconds: 60,
|
||||||
|
|
@ -168,6 +178,7 @@ var (
|
||||||
RelativeMouse: true,
|
RelativeMouse: true,
|
||||||
Keyboard: true,
|
Keyboard: true,
|
||||||
MassStorage: true,
|
MassStorage: true,
|
||||||
|
Audio: true,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -181,6 +192,7 @@ func getDefaultConfig() Config {
|
||||||
KeyboardMacros: []KeyboardMacro{},
|
KeyboardMacros: []KeyboardMacro{},
|
||||||
DisplayRotation: "270",
|
DisplayRotation: "270",
|
||||||
KeyboardLayout: "en-US",
|
KeyboardLayout: "en-US",
|
||||||
|
EdidString: native.DefaultEDID,
|
||||||
DisplayMaxBrightness: 64,
|
DisplayMaxBrightness: 64,
|
||||||
DisplayDimAfterSec: 120, // 2 minutes
|
DisplayDimAfterSec: 120, // 2 minutes
|
||||||
DisplayOffAfterSec: 1800, // 30 minutes
|
DisplayOffAfterSec: 1800, // 30 minutes
|
||||||
|
|
@ -195,8 +207,17 @@ func getDefaultConfig() Config {
|
||||||
_ = confparser.SetDefaultsAndValidate(c)
|
_ = confparser.SetDefaultsAndValidate(c)
|
||||||
return c
|
return c
|
||||||
}(),
|
}(),
|
||||||
DefaultLogLevel: "INFO",
|
DefaultLogLevel: "INFO",
|
||||||
VideoQualityFactor: 1.0,
|
VideoQualityFactor: 1.0,
|
||||||
|
AudioInputAutoEnable: false,
|
||||||
|
AudioOutputEnabled: true,
|
||||||
|
AudioOutputSource: "usb",
|
||||||
|
AudioBitrate: 192,
|
||||||
|
AudioComplexity: 8,
|
||||||
|
AudioDTXEnabled: true,
|
||||||
|
AudioFECEnabled: true,
|
||||||
|
AudioBufferPeriods: 12,
|
||||||
|
AudioPacketLossPerc: 20,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,6 +288,17 @@ func LoadConfig() {
|
||||||
loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig
|
loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply audio defaults for new configs
|
||||||
|
if loadedConfig.AudioBitrate == 0 {
|
||||||
|
defaults := getDefaultConfig()
|
||||||
|
loadedConfig.AudioBitrate = defaults.AudioBitrate
|
||||||
|
loadedConfig.AudioComplexity = defaults.AudioComplexity
|
||||||
|
loadedConfig.AudioDTXEnabled = defaults.AudioDTXEnabled
|
||||||
|
loadedConfig.AudioFECEnabled = defaults.AudioFECEnabled
|
||||||
|
loadedConfig.AudioBufferPeriods = defaults.AudioBufferPeriods
|
||||||
|
loadedConfig.AudioPacketLossPerc = defaults.AudioPacketLossPerc
|
||||||
|
}
|
||||||
|
|
||||||
// fixup old keyboard layout value
|
// fixup old keyboard layout value
|
||||||
if loadedConfig.KeyboardLayout == "en_US" {
|
if loadedConfig.KeyboardLayout == "en_US" {
|
||||||
loadedConfig.KeyboardLayout = "en-US"
|
loadedConfig.KeyboardLayout = "en-US"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,250 @@
|
||||||
|
//go:build linux && (arm || arm64)
|
||||||
|
|
||||||
|
package audio
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CFLAGS: -O3 -ffast-math -I/opt/jetkvm-audio-libs/alsa-lib-1.2.14/include -I/opt/jetkvm-audio-libs/opus-1.5.2/include -I/opt/jetkvm-audio-libs/speexdsp-1.2.1/include
|
||||||
|
#cgo LDFLAGS: /opt/jetkvm-audio-libs/alsa-lib-1.2.14/src/.libs/libasound.a /opt/jetkvm-audio-libs/opus-1.5.2/.libs/libopus.a /opt/jetkvm-audio-libs/speexdsp-1.2.1/libspeexdsp/.libs/libspeexdsp.a -lm -ldl -lpthread
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include "c/audio.c"
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ipcMaxFrameSize = 1500
|
||||||
|
)
|
||||||
|
|
||||||
|
type CgoSource struct {
|
||||||
|
outputDevice bool
|
||||||
|
alsaDevice string
|
||||||
|
connected bool
|
||||||
|
mu sync.Mutex
|
||||||
|
logger zerolog.Logger
|
||||||
|
opusBuf []byte
|
||||||
|
config AudioConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ AudioSource = (*CgoSource)(nil)
|
||||||
|
|
||||||
|
func NewCgoOutputSource(alsaDevice string, cfg AudioConfig) AudioSource {
|
||||||
|
logger := logging.GetDefaultLogger().With().
|
||||||
|
Str("component", "audio-output-cgo").
|
||||||
|
Str("alsa_device", alsaDevice).
|
||||||
|
Logger()
|
||||||
|
|
||||||
|
return &CgoSource{
|
||||||
|
outputDevice: true,
|
||||||
|
alsaDevice: alsaDevice,
|
||||||
|
logger: logger,
|
||||||
|
opusBuf: make([]byte, ipcMaxFrameSize),
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCgoInputSource(alsaDevice string, cfg AudioConfig) AudioSource {
|
||||||
|
logger := logging.GetDefaultLogger().With().
|
||||||
|
Str("component", "audio-input-cgo").
|
||||||
|
Str("alsa_device", alsaDevice).
|
||||||
|
Logger()
|
||||||
|
|
||||||
|
return &CgoSource{
|
||||||
|
outputDevice: false,
|
||||||
|
alsaDevice: alsaDevice,
|
||||||
|
logger: logger,
|
||||||
|
opusBuf: make([]byte, ipcMaxFrameSize),
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CgoSource) Connect() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.connected {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.outputDevice {
|
||||||
|
return c.connectOutput()
|
||||||
|
}
|
||||||
|
return c.connectInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CgoSource) connectOutput() error {
|
||||||
|
if err := os.Setenv("ALSA_CAPTURE_DEVICE", c.alsaDevice); err != nil {
|
||||||
|
c.logger.Warn().Err(err).Str("device", c.alsaDevice).Msg("Failed to set ALSA_CAPTURE_DEVICE")
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleRate = 48000
|
||||||
|
const frameSize = uint16(sampleRate * 20 / 1000) // 20ms frames
|
||||||
|
|
||||||
|
c.logger.Debug().
|
||||||
|
Uint16("bitrate_kbps", c.config.Bitrate).
|
||||||
|
Uint8("complexity", c.config.Complexity).
|
||||||
|
Bool("dtx", c.config.DTXEnabled).
|
||||||
|
Bool("fec", c.config.FECEnabled).
|
||||||
|
Uint8("buffer_periods", c.config.BufferPeriods).
|
||||||
|
Uint32("sample_rate", sampleRate).
|
||||||
|
Uint16("frame_size", uint16(frameSize)).
|
||||||
|
Uint8("packet_loss_perc", c.config.PacketLossPerc).
|
||||||
|
Msg("Initializing audio capture")
|
||||||
|
|
||||||
|
C.update_audio_constants(
|
||||||
|
C.uint(uint32(c.config.Bitrate)*1000),
|
||||||
|
C.uchar(c.config.Complexity),
|
||||||
|
C.uchar(2),
|
||||||
|
C.ushort(1500),
|
||||||
|
C.uint(1000),
|
||||||
|
C.uchar(5),
|
||||||
|
C.uint(500000),
|
||||||
|
boolToUchar(c.config.DTXEnabled),
|
||||||
|
boolToUchar(c.config.FECEnabled),
|
||||||
|
C.uchar(c.config.BufferPeriods),
|
||||||
|
C.uchar(c.config.PacketLossPerc),
|
||||||
|
)
|
||||||
|
|
||||||
|
rc := C.jetkvm_audio_capture_init()
|
||||||
|
if rc != 0 {
|
||||||
|
c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio capture")
|
||||||
|
return fmt.Errorf("jetkvm_audio_capture_init failed: %d", rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.connected = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CgoSource) connectInput() error {
|
||||||
|
if err := os.Setenv("ALSA_PLAYBACK_DEVICE", c.alsaDevice); err != nil {
|
||||||
|
c.logger.Warn().Err(err).Str("device", c.alsaDevice).Msg("Failed to set ALSA_PLAYBACK_DEVICE")
|
||||||
|
}
|
||||||
|
|
||||||
|
C.update_audio_decoder_constants(
|
||||||
|
C.uchar(1), // Mono for USB audio gadget
|
||||||
|
C.ushort(1500),
|
||||||
|
C.uint(1000),
|
||||||
|
C.uchar(5),
|
||||||
|
C.uint(500000),
|
||||||
|
C.uchar(c.config.BufferPeriods),
|
||||||
|
)
|
||||||
|
|
||||||
|
rc := C.jetkvm_audio_playback_init()
|
||||||
|
if rc != 0 {
|
||||||
|
c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio playback")
|
||||||
|
return fmt.Errorf("jetkvm_audio_playback_init failed: %d", rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.connected = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToUchar(b bool) C.uchar {
|
||||||
|
if b {
|
||||||
|
return C.uchar(1)
|
||||||
|
}
|
||||||
|
return C.uchar(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CgoSource) Disconnect() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if !c.connected {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.outputDevice {
|
||||||
|
C.jetkvm_audio_capture_close()
|
||||||
|
os.Unsetenv("ALSA_CAPTURE_DEVICE")
|
||||||
|
} else {
|
||||||
|
C.jetkvm_audio_playback_close()
|
||||||
|
os.Unsetenv("ALSA_PLAYBACK_DEVICE")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CgoSource) IsConnected() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.connected
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CgoSource) ReadMessage() (uint8, []byte, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if !c.connected {
|
||||||
|
return 0, nil, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.outputDevice {
|
||||||
|
return 0, nil, fmt.Errorf("ReadMessage only supported for output direction")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hold mutex during C call to prevent race condition with Disconnect().
|
||||||
|
// Lock order is consistent (c.mu -> capture_mutex) in all code paths,
|
||||||
|
// so this cannot deadlock. The C layer's capture_mutex protects ALSA/codec
|
||||||
|
// state, while c.mu protects the connection lifecycle.
|
||||||
|
opusSize := C.jetkvm_audio_read_encode(unsafe.Pointer(&c.opusBuf[0]))
|
||||||
|
if opusSize < 0 {
|
||||||
|
return 0, nil, fmt.Errorf("jetkvm_audio_read_encode failed: %d", opusSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opusSize == 0 {
|
||||||
|
return 0, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if int(opusSize) > len(c.opusBuf) {
|
||||||
|
return 0, nil, fmt.Errorf("opus packet too large: %d > %d", opusSize, len(c.opusBuf))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a copy to prevent buffer aliasing - the caller may hold this slice
|
||||||
|
// while the next ReadMessage overwrites the internal buffer
|
||||||
|
result := make([]byte, opusSize)
|
||||||
|
copy(result, c.opusBuf[:opusSize])
|
||||||
|
return ipcMsgTypeOpus, result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CgoSource) WriteMessage(msgType uint8, payload []byte) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if !c.connected {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.outputDevice {
|
||||||
|
return fmt.Errorf("WriteMessage only supported for input direction")
|
||||||
|
}
|
||||||
|
|
||||||
|
if msgType != ipcMsgTypeOpus {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(payload) > 1500 {
|
||||||
|
return fmt.Errorf("opus packet too large: %d bytes (max 1500)", len(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hold mutex during C call to prevent race condition with Disconnect().
|
||||||
|
// Lock order is consistent (c.mu -> playback_mutex) in all code paths.
|
||||||
|
rc := C.jetkvm_audio_decode_write(unsafe.Pointer(&payload[0]), C.int(len(payload)))
|
||||||
|
if rc < 0 {
|
||||||
|
return fmt.Errorf("jetkvm_audio_decode_write failed: %d", rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
//go:build !linux || (!arm && !arm64)
|
||||||
|
|
||||||
|
package audio
|
||||||
|
|
||||||
|
// Stub implementations for non-ARM Linux platforms
|
||||||
|
|
||||||
|
type CgoSource struct{}
|
||||||
|
|
||||||
|
var _ AudioSource = (*CgoSource)(nil)
|
||||||
|
|
||||||
|
func NewCgoOutputSource(alsaDevice string, audioConfig AudioConfig) AudioSource {
|
||||||
|
panic("audio CGO source not supported on this platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCgoInputSource(alsaDevice string, audioConfig AudioConfig) AudioSource {
|
||||||
|
panic("audio CGO source not supported on this platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CgoSource) Connect() error {
|
||||||
|
panic("audio CGO source not supported on this platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CgoSource) Disconnect() {
|
||||||
|
panic("audio CGO source not supported on this platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CgoSource) IsConnected() bool {
|
||||||
|
panic("audio CGO source not supported on this platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CgoSource) ReadMessage() (uint8, []byte, error) {
|
||||||
|
panic("audio CGO source not supported on this platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CgoSource) WriteMessage(msgType uint8, payload []byte) error {
|
||||||
|
panic("audio CGO source not supported on this platform")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/pion/webrtc/v4/pkg/media"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OutputRelay struct {
|
||||||
|
source *AudioSource
|
||||||
|
audioTrack *webrtc.TrackLocalStaticSample
|
||||||
|
cancel context.CancelFunc
|
||||||
|
logger zerolog.Logger
|
||||||
|
running atomic.Bool
|
||||||
|
sample media.Sample
|
||||||
|
stopped chan struct{}
|
||||||
|
|
||||||
|
framesRelayed atomic.Uint32
|
||||||
|
framesDropped atomic.Uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOutputRelay(source *AudioSource, audioTrack *webrtc.TrackLocalStaticSample) *OutputRelay {
|
||||||
|
_, cancel := context.WithCancel(context.Background())
|
||||||
|
logger := logging.GetDefaultLogger().With().Str("component", "audio-output-relay").Logger()
|
||||||
|
|
||||||
|
return &OutputRelay{
|
||||||
|
source: source,
|
||||||
|
audioTrack: audioTrack,
|
||||||
|
cancel: cancel,
|
||||||
|
logger: logger,
|
||||||
|
stopped: make(chan struct{}),
|
||||||
|
sample: media.Sample{
|
||||||
|
Duration: 20 * time.Millisecond,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OutputRelay) Start() error {
|
||||||
|
if r.running.Swap(true) {
|
||||||
|
return fmt.Errorf("output relay already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
go r.relayLoop()
|
||||||
|
r.logger.Debug().Msg("output relay started")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OutputRelay) Stop() {
|
||||||
|
if !r.running.Swap(false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.cancel()
|
||||||
|
<-r.stopped
|
||||||
|
|
||||||
|
r.logger.Debug().
|
||||||
|
Uint32("frames_relayed", r.framesRelayed.Load()).
|
||||||
|
Uint32("frames_dropped", r.framesDropped.Load()).
|
||||||
|
Msg("output relay stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OutputRelay) relayLoop() {
|
||||||
|
defer close(r.stopped)
|
||||||
|
|
||||||
|
const maxRetries = 10
|
||||||
|
const maxConsecutiveWriteFailures = 50 // Allow some WebRTC write failures before reconnecting
|
||||||
|
retryDelay := 1 * time.Second
|
||||||
|
consecutiveFailures := 0
|
||||||
|
consecutiveWriteFailures := 0
|
||||||
|
|
||||||
|
for r.running.Load() {
|
||||||
|
if !(*r.source).IsConnected() {
|
||||||
|
if err := (*r.source).Connect(); err != nil {
|
||||||
|
if consecutiveFailures++; consecutiveFailures >= maxRetries {
|
||||||
|
r.logger.Error().Int("failures", consecutiveFailures).Msg("Max retries exceeded, stopping relay")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.logger.Debug().Err(err).Int("failures", consecutiveFailures).Msg("Connection failed, retrying")
|
||||||
|
time.Sleep(retryDelay)
|
||||||
|
retryDelay = min(retryDelay*2, 30*time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
consecutiveFailures = 0
|
||||||
|
retryDelay = 1 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
msgType, payload, err := (*r.source).ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if !r.running.Load() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if consecutiveFailures++; consecutiveFailures >= maxRetries {
|
||||||
|
r.logger.Error().Int("failures", consecutiveFailures).Msg("Max read retries exceeded, stopping relay")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.logger.Warn().Err(err).Int("failures", consecutiveFailures).Msg("Read error, reconnecting")
|
||||||
|
(*r.source).Disconnect()
|
||||||
|
time.Sleep(retryDelay)
|
||||||
|
retryDelay = min(retryDelay*2, 30*time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
consecutiveFailures = 0
|
||||||
|
retryDelay = 1 * time.Second
|
||||||
|
|
||||||
|
if msgType == ipcMsgTypeOpus && len(payload) > 0 {
|
||||||
|
r.sample.Data = payload
|
||||||
|
if err := r.audioTrack.WriteSample(r.sample); err != nil {
|
||||||
|
r.framesDropped.Add(1)
|
||||||
|
consecutiveWriteFailures++
|
||||||
|
|
||||||
|
// Log warning on first failure and every 10th failure
|
||||||
|
if consecutiveWriteFailures == 1 || consecutiveWriteFailures%10 == 0 {
|
||||||
|
r.logger.Warn().
|
||||||
|
Err(err).
|
||||||
|
Int("consecutive_failures", consecutiveWriteFailures).
|
||||||
|
Msg("Failed to write sample to WebRTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
if consecutiveWriteFailures >= maxConsecutiveWriteFailures {
|
||||||
|
r.logger.Error().
|
||||||
|
Int("failures", consecutiveWriteFailures).
|
||||||
|
Msg("Too many consecutive WebRTC write failures, reconnecting source")
|
||||||
|
(*r.source).Disconnect()
|
||||||
|
consecutiveWriteFailures = 0
|
||||||
|
consecutiveFailures = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
r.framesRelayed.Add(1)
|
||||||
|
consecutiveWriteFailures = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type InputRelay struct {
|
||||||
|
logger zerolog.Logger
|
||||||
|
running atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInputRelay() *InputRelay {
|
||||||
|
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-relay").Logger()
|
||||||
|
|
||||||
|
return &InputRelay{
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InputRelay) Start() error {
|
||||||
|
if r.running.Swap(true) {
|
||||||
|
return fmt.Errorf("input relay already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Debug().Msg("input relay started")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InputRelay) Stop() {
|
||||||
|
if !r.running.Swap(false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Debug().Msg("input relay stopped")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
const (
|
||||||
|
ipcMsgTypeOpus = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
type AudioConfig struct {
|
||||||
|
Bitrate uint16
|
||||||
|
Complexity uint8
|
||||||
|
BufferPeriods uint8
|
||||||
|
DTXEnabled bool
|
||||||
|
FECEnabled bool
|
||||||
|
PacketLossPerc uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultAudioConfig() AudioConfig {
|
||||||
|
return AudioConfig{
|
||||||
|
Bitrate: 192,
|
||||||
|
Complexity: 8,
|
||||||
|
BufferPeriods: 12,
|
||||||
|
DTXEnabled: true,
|
||||||
|
FECEnabled: true,
|
||||||
|
PacketLossPerc: 20,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioSource interface {
|
||||||
|
ReadMessage() (msgType uint8, payload []byte, err error)
|
||||||
|
WriteMessage(msgType uint8, payload []byte) error
|
||||||
|
IsConnected() bool
|
||||||
|
Connect() error
|
||||||
|
Disconnect()
|
||||||
|
}
|
||||||
|
|
@ -769,13 +769,12 @@ uint8_t video_get_streaming_status() {
|
||||||
void video_restart_streaming()
|
void video_restart_streaming()
|
||||||
{
|
{
|
||||||
uint8_t streaming_status = video_get_streaming_status();
|
uint8_t streaming_status = video_get_streaming_status();
|
||||||
if (streaming_status == 0)
|
|
||||||
{
|
if (streaming_status == 0 && !detected_signal) {
|
||||||
log_info("will not restart video streaming because it's stopped");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streaming_status == 2) {
|
if (streaming_status != 0) {
|
||||||
video_stop_streaming();
|
video_stop_streaming();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -802,7 +801,6 @@ void *run_detect_format(void *arg)
|
||||||
|
|
||||||
while (!should_exit)
|
while (!should_exit)
|
||||||
{
|
{
|
||||||
ensure_sleep_mode_disabled();
|
|
||||||
|
|
||||||
memset(&dv_timings, 0, sizeof(dv_timings));
|
memset(&dv_timings, 0, sizeof(dv_timings));
|
||||||
if (ioctl(sub_dev_fd, VIDIOC_QUERY_DV_TIMINGS, &dv_timings) != 0)
|
if (ioctl(sub_dev_fd, VIDIOC_QUERY_DV_TIMINGS, &dv_timings) != 0)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import (
|
||||||
|
|
||||||
const sleepModeFile = "/sys/devices/platform/ff470000.i2c/i2c-4/4-000f/sleep_mode"
|
const sleepModeFile = "/sys/devices/platform/ff470000.i2c/i2c-4/4-000f/sleep_mode"
|
||||||
|
|
||||||
// DefaultEDID is the default EDID for the video stream.
|
|
||||||
const DefaultEDID = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
|
const DefaultEDID = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
|
||||||
|
|
||||||
var extraLockTimeout = 5 * time.Second
|
var extraLockTimeout = 5 * time.Second
|
||||||
|
|
@ -153,10 +152,6 @@ func (n *Native) VideoSetEDID(edid string) error {
|
||||||
n.videoLock.Lock()
|
n.videoLock.Lock()
|
||||||
defer n.videoLock.Unlock()
|
defer n.videoLock.Unlock()
|
||||||
|
|
||||||
if edid == "" {
|
|
||||||
edid = DefaultEDID
|
|
||||||
}
|
|
||||||
|
|
||||||
return n.useExtraLock(func() error {
|
return n.useExtraLock(func() error {
|
||||||
return videoSetEDID(edid)
|
return videoSetEDID(edid)
|
||||||
})
|
})
|
||||||
|
|
@ -170,6 +165,11 @@ func (n *Native) VideoGetEDID() (string, error) {
|
||||||
return videoGetEDID()
|
return videoGetEDID()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDefaultEDID returns the default EDID constant.
|
||||||
|
func (n *Native) GetDefaultEDID() string {
|
||||||
|
return DefaultEDID
|
||||||
|
}
|
||||||
|
|
||||||
// VideoLogStatus gets the log status for the video stream.
|
// VideoLogStatus gets the log status for the video stream.
|
||||||
func (n *Native) VideoLogStatus() (string, error) {
|
func (n *Native) VideoLogStatus() (string, error) {
|
||||||
n.videoLock.Lock()
|
n.videoLock.Lock()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// Code generated by "go run gen.go". DO NOT EDIT.
|
// Code generated by "go run gen.go". DO NOT EDIT.
|
||||||
|
//
|
||||||
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go
|
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go
|
||||||
package tzdata
|
package tzdata
|
||||||
|
|
||||||
var TimeZones = []string{
|
var TimeZones = []string{
|
||||||
"Africa/Abidjan",
|
"Africa/Abidjan",
|
||||||
"Africa/Accra",
|
"Africa/Accra",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package usbgadget
|
package usbgadget
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/sourcegraph/tf-dag/dag"
|
"github.com/sourcegraph/tf-dag/dag"
|
||||||
|
|
@ -114,7 +116,20 @@ func (c *ChangeSetResolver) resolveChanges(initial bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ChangeSetResolver) applyChanges() error {
|
func (c *ChangeSetResolver) applyChanges() error {
|
||||||
|
return c.applyChangesWithTimeout(45 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ChangeSetResolver) applyChangesWithTimeout(timeout time.Duration) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
for _, change := range c.resolvedChanges {
|
for _, change := range c.resolvedChanges {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("USB gadget reconfiguration timed out after %v: %w", timeout, ctx.Err())
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
change.ResetActionResolution()
|
change.ResetActionResolution()
|
||||||
action := change.Action()
|
action := change.Action()
|
||||||
actionStr := FileChangeResolvedActionString[action]
|
actionStr := FileChangeResolvedActionString[action]
|
||||||
|
|
@ -126,7 +141,7 @@ func (c *ChangeSetResolver) applyChanges() error {
|
||||||
|
|
||||||
l.Str("action", actionStr).Str("change", change.String()).Msg("applying change")
|
l.Str("action", actionStr).Str("change", change.String()).Msg("applying change")
|
||||||
|
|
||||||
err := c.changeset.applyChange(change)
|
err := c.applyChangeWithTimeout(ctx, change)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if change.IgnoreErrors {
|
if change.IgnoreErrors {
|
||||||
c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error")
|
c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error")
|
||||||
|
|
@ -139,6 +154,20 @@ func (c *ChangeSetResolver) applyChanges() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ChangeSetResolver) applyChangeWithTimeout(ctx context.Context, change *FileChange) error {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- c.changeset.applyChange(change)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("change application timed out for %s: %w", change.String(), ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ChangeSetResolver) GetChanges() ([]*FileChange, error) {
|
func (c *ChangeSetResolver) GetChanges() ([]*FileChange, error) {
|
||||||
localChanges := c.changeset.Changes
|
localChanges := c.changeset.Changes
|
||||||
changesMap := make(map[string]*FileChange)
|
changesMap := make(map[string]*FileChange)
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,23 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
|
||||||
// mass storage
|
// mass storage
|
||||||
"mass_storage_base": massStorageBaseConfig,
|
"mass_storage_base": massStorageBaseConfig,
|
||||||
"mass_storage_lun0": massStorageLun0Config,
|
"mass_storage_lun0": massStorageLun0Config,
|
||||||
|
// audio (UAC1 - USB Audio Class 1)
|
||||||
|
"audio": {
|
||||||
|
order: 4000,
|
||||||
|
device: "uac1.usb0",
|
||||||
|
path: []string{"functions", "uac1.usb0"},
|
||||||
|
configPath: []string{"uac1.usb0"},
|
||||||
|
attrs: gadgetAttributes{
|
||||||
|
"p_chmask": "1", // Playback: mono (1 channel for microphone)
|
||||||
|
"p_srate": "48000", // Playback: 48kHz sample rate
|
||||||
|
"p_ssize": "2", // Playback: 16-bit (2 bytes)
|
||||||
|
"p_volume_present": "1", // Playback: enable volume control
|
||||||
|
"c_chmask": "3", // Capture: stereo (2 channels for HDMI audio)
|
||||||
|
"c_srate": "48000", // Capture: 48kHz sample rate
|
||||||
|
"c_ssize": "2", // Capture: 16-bit (2 bytes)
|
||||||
|
"c_volume_present": "0", // Capture: no volume control
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
|
func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
|
||||||
|
|
@ -73,6 +90,8 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
|
||||||
return u.enabledDevices.MassStorage
|
return u.enabledDevices.MassStorage
|
||||||
case "mass_storage_lun0":
|
case "mass_storage_lun0":
|
||||||
return u.enabledDevices.MassStorage
|
return u.enabledDevices.MassStorage
|
||||||
|
case "audio":
|
||||||
|
return u.enabledDevices.Audio
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +134,13 @@ func (u *UsbGadget) SetGadgetDevices(devices *Devices) {
|
||||||
u.enabledDevices = *devices
|
u.enabledDevices = *devices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) GetGadgetDevices() Devices {
|
||||||
|
u.configLock.Lock()
|
||||||
|
defer u.configLock.Unlock()
|
||||||
|
|
||||||
|
return u.enabledDevices
|
||||||
|
}
|
||||||
|
|
||||||
// GetConfigPath returns the path to the config item.
|
// GetConfigPath returns the path to the config item.
|
||||||
func (u *UsbGadget) GetConfigPath(itemKey string) (string, error) {
|
func (u *UsbGadget) GetConfigPath(itemKey string) (string, error) {
|
||||||
item, ok := u.configMap[itemKey]
|
item, ok := u.configMap[itemKey]
|
||||||
|
|
@ -182,6 +208,9 @@ func (u *UsbGadget) Init() error {
|
||||||
return u.logError("unable to initialize USB stack", err)
|
return u.logError("unable to initialize USB stack", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-open HID files to reduce input latency
|
||||||
|
u.PreOpenHidFiles()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,11 +220,17 @@ func (u *UsbGadget) UpdateGadgetConfig() error {
|
||||||
|
|
||||||
u.loadGadgetConfig()
|
u.loadGadgetConfig()
|
||||||
|
|
||||||
|
// Close HID files before reconfiguration to prevent "file already closed" errors
|
||||||
|
u.CloseHidFiles()
|
||||||
|
|
||||||
err := u.configureUsbGadget(true)
|
err := u.configureUsbGadget(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u.logError("unable to update gadget config", err)
|
return u.logError("unable to update gadget config", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reopen HID files after reconfiguration
|
||||||
|
u.PreOpenHidFiles()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package usbgadget
|
package usbgadget
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
@ -52,22 +54,49 @@ func (u *UsbGadget) newUsbGadgetTransaction(lock bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) WithTransaction(fn func() error) error {
|
func (u *UsbGadget) WithTransaction(fn func() error) error {
|
||||||
u.txLock.Lock()
|
return u.WithTransactionTimeout(fn, 60*time.Second)
|
||||||
defer u.txLock.Unlock()
|
}
|
||||||
|
|
||||||
err := u.newUsbGadgetTransaction(false)
|
// WithTransactionTimeout executes a USB gadget transaction with a specified timeout
|
||||||
if err != nil {
|
// to prevent indefinite blocking during USB reconfiguration operations
|
||||||
u.log.Error().Err(err).Msg("failed to create transaction")
|
func (u *UsbGadget) WithTransactionTimeout(fn func() error, timeout time.Duration) error {
|
||||||
return err
|
// Create a context with timeout for the entire transaction
|
||||||
}
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
if err := fn(); err != nil {
|
defer cancel()
|
||||||
u.log.Error().Err(err).Msg("transaction failed")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
result := u.tx.Commit()
|
|
||||||
u.tx = nil
|
|
||||||
|
|
||||||
return result
|
// Channel to signal when the transaction is complete
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
// Execute the transaction in a goroutine
|
||||||
|
go func() {
|
||||||
|
u.txLock.Lock()
|
||||||
|
defer u.txLock.Unlock()
|
||||||
|
|
||||||
|
err := u.newUsbGadgetTransaction(false)
|
||||||
|
if err != nil {
|
||||||
|
u.log.Error().Err(err).Msg("failed to create transaction")
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fn(); err != nil {
|
||||||
|
u.log.Error().Err(err).Msg("transaction failed")
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := u.tx.Commit()
|
||||||
|
u.tx = nil
|
||||||
|
done <- result
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for either completion or timeout
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("USB gadget transaction timed out after %v: %w", timeout, ctx.Err())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *UsbGadgetTransaction) addFileChange(component string, change RequestedFileChange) string {
|
func (tx *UsbGadgetTransaction) addFileChange(component string, change RequestedFileChange) string {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,16 @@ type Devices struct {
|
||||||
RelativeMouse bool `json:"relative_mouse"`
|
RelativeMouse bool `json:"relative_mouse"`
|
||||||
Keyboard bool `json:"keyboard"`
|
Keyboard bool `json:"keyboard"`
|
||||||
MassStorage bool `json:"mass_storage"`
|
MassStorage bool `json:"mass_storage"`
|
||||||
|
Audio bool `json:"audio"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equals checks if two Devices structs are equal.
|
||||||
|
func (d Devices) Equals(other Devices) bool {
|
||||||
|
return d.AbsoluteMouse == other.AbsoluteMouse &&
|
||||||
|
d.RelativeMouse == other.RelativeMouse &&
|
||||||
|
d.Keyboard == other.Keyboard &&
|
||||||
|
d.MassStorage == other.MassStorage &&
|
||||||
|
d.Audio == other.Audio
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config is a struct that represents the customizations for a USB gadget.
|
// Config is a struct that represents the customizations for a USB gadget.
|
||||||
|
|
@ -39,6 +49,7 @@ var defaultUsbGadgetDevices = Devices{
|
||||||
RelativeMouse: true,
|
RelativeMouse: true,
|
||||||
Keyboard: true,
|
Keyboard: true,
|
||||||
MassStorage: true,
|
MassStorage: true,
|
||||||
|
Audio: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
type KeysDownState struct {
|
type KeysDownState struct {
|
||||||
|
|
@ -188,3 +199,52 @@ func (u *UsbGadget) Close() error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CloseHidFiles closes all open HID files
|
||||||
|
func (u *UsbGadget) CloseHidFiles() {
|
||||||
|
u.log.Debug().Msg("closing HID files")
|
||||||
|
|
||||||
|
closeFile := func(file **os.File, name string) {
|
||||||
|
if *file != nil {
|
||||||
|
if err := (*file).Close(); err != nil {
|
||||||
|
u.log.Debug().Err(err).Msgf("failed to close %s HID file", name)
|
||||||
|
}
|
||||||
|
*file = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeFile(&u.keyboardHidFile, "keyboard")
|
||||||
|
closeFile(&u.absMouseHidFile, "absolute mouse")
|
||||||
|
closeFile(&u.relMouseHidFile, "relative mouse")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreOpenHidFiles opens all HID files to reduce input latency
|
||||||
|
func (u *UsbGadget) PreOpenHidFiles() {
|
||||||
|
// Small delay for USB gadget reconfiguration to complete
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
openHidFile := func(file **os.File, path string, name string) {
|
||||||
|
if *file == nil {
|
||||||
|
f, err := os.OpenFile(path, os.O_RDWR, 0666)
|
||||||
|
if err != nil {
|
||||||
|
u.log.Debug().Err(err).Msgf("failed to pre-open %s HID file", name)
|
||||||
|
} else {
|
||||||
|
*file = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.enabledDevices.Keyboard {
|
||||||
|
if err := u.openKeyboardHidFile(); err != nil {
|
||||||
|
u.log.Debug().Err(err).Msg("failed to pre-open keyboard HID file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.enabledDevices.AbsoluteMouse {
|
||||||
|
openHidFile(&u.absMouseHidFile, "/dev/hidg1", "absolute mouse")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.enabledDevices.RelativeMouse {
|
||||||
|
openHidFile(&u.relMouseHidFile, "/dev/hidg2", "relative mouse")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
395
jsonrpc.go
395
jsonrpc.go
|
|
@ -19,6 +19,7 @@ import (
|
||||||
"go.bug.st/serial"
|
"go.bug.st/serial"
|
||||||
|
|
||||||
"github.com/jetkvm/kvm/internal/hidrpc"
|
"github.com/jetkvm/kvm/internal/hidrpc"
|
||||||
|
"github.com/jetkvm/kvm/internal/native"
|
||||||
"github.com/jetkvm/kvm/internal/usbgadget"
|
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||||
"github.com/jetkvm/kvm/internal/utils"
|
"github.com/jetkvm/kvm/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
@ -123,7 +124,6 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
Interface("id", request.ID).Logger()
|
Interface("id", request.ID).Logger()
|
||||||
|
|
||||||
scopedLogger.Trace().Msg("Received RPC request")
|
scopedLogger.Trace().Msg("Received RPC request")
|
||||||
t := time.Now()
|
|
||||||
|
|
||||||
handler, ok := rpcHandlers[request.Method]
|
handler, ok := rpcHandlers[request.Method]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -155,7 +155,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scopedLogger.Trace().Dur("duration", time.Since(t)).Interface("result", result).Msg("RPC handler returned")
|
scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned")
|
||||||
|
|
||||||
response := JSONRPCResponse{
|
response := JSONRPCResponse{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
|
|
@ -209,15 +209,16 @@ func rpcSetAutoUpdateState(enabled bool) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetEDID() (string, error) {
|
func rpcGetEDID() (string, error) {
|
||||||
resp, err := nativeInstance.VideoGetEDID()
|
return config.EdidString, nil
|
||||||
if err != nil {
|
}
|
||||||
return "", err
|
|
||||||
}
|
func rpcGetDefaultEDID() (string, error) {
|
||||||
return resp, nil
|
return native.DefaultEDID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetEDID(edid string) error {
|
func rpcSetEDID(edid string) error {
|
||||||
if edid == "" {
|
if edid == "" {
|
||||||
|
edid = native.DefaultEDID
|
||||||
logger.Info().Msg("Restoring EDID to default")
|
logger.Info().Msg("Restoring EDID to default")
|
||||||
} else {
|
} else {
|
||||||
logger.Info().Str("edid", edid).Msg("Setting EDID")
|
logger.Info().Str("edid", edid).Msg("Setting EDID")
|
||||||
|
|
@ -227,12 +228,26 @@ func rpcSetEDID(edid string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save EDID to config, allowing it to be restored on reboot.
|
|
||||||
config.EdidString = edid
|
config.EdidString = edid
|
||||||
_ = SaveConfig()
|
if err := SaveConfig(); err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to save config after EDID change")
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcRefreshHdmiConnection() error {
|
||||||
|
currentEDID, err := nativeInstance.VideoGetEDID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if currentEDID == "" {
|
||||||
|
// Use the default EDID from the native package
|
||||||
|
currentEDID = native.DefaultEDID
|
||||||
|
}
|
||||||
|
return nativeInstance.VideoSetEDID(currentEDID)
|
||||||
|
}
|
||||||
|
|
||||||
func rpcGetVideoLogStatus() (string, error) {
|
func rpcGetVideoLogStatus() (string, error) {
|
||||||
return nativeInstance.VideoLogStatus()
|
return nativeInstance.VideoLogStatus()
|
||||||
}
|
}
|
||||||
|
|
@ -436,7 +451,7 @@ type RPCHandler struct {
|
||||||
Params []string
|
Params []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls
|
// call the handler but recover from a panic to ensure our RPC goroutine doesn't collapse on malformed calls
|
||||||
func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (result any, err error) {
|
func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (result any, err error) {
|
||||||
// Use defer to recover from a panic
|
// Use defer to recover from a panic
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
@ -606,6 +621,9 @@ func rpcGetMassStorageMode() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcIsUpdatePending() (bool, error) {
|
func rpcIsUpdatePending() (bool, error) {
|
||||||
|
if otaState == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
return otaState.IsUpdatePending(), nil
|
return otaState.IsUpdatePending(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -628,9 +646,12 @@ func rpcGetUsbConfig() (usbgadget.Config, error) {
|
||||||
|
|
||||||
func rpcSetUsbConfig(usbConfig usbgadget.Config) error {
|
func rpcSetUsbConfig(usbConfig usbgadget.Config) error {
|
||||||
LoadConfig()
|
LoadConfig()
|
||||||
|
wasUsbAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio
|
||||||
|
|
||||||
config.UsbConfig = &usbConfig
|
config.UsbConfig = &usbConfig
|
||||||
gadget.SetGadgetConfig(config.UsbConfig)
|
gadget.SetGadgetConfig(config.UsbConfig)
|
||||||
return updateUsbRelatedConfig()
|
|
||||||
|
return updateUsbRelatedConfig(wasUsbAudioEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) {
|
func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) {
|
||||||
|
|
@ -842,23 +863,60 @@ func rpcGetUsbDevices() (usbgadget.Devices, error) {
|
||||||
return *config.UsbDevices, nil
|
return *config.UsbDevices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUsbRelatedConfig() error {
|
func updateUsbRelatedConfig(wasUsbAudioEnabled bool) error {
|
||||||
if err := gadget.UpdateGadgetConfig(); err != nil {
|
ensureConfigLoaded()
|
||||||
return fmt.Errorf("failed to write gadget config: %w", err)
|
nowHasUsbAudio := config.UsbDevices != nil && config.UsbDevices.Audio
|
||||||
|
|
||||||
|
// Stop audio before reconfiguring USB gadget
|
||||||
|
stopInputAudio()
|
||||||
|
if config.AudioOutputSource == "usb" {
|
||||||
|
stopOutputAudio()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-switch to HDMI when USB audio disabled
|
||||||
|
if wasUsbAudioEnabled && !nowHasUsbAudio && config.AudioOutputSource == "usb" {
|
||||||
|
logger.Info().Msg("USB audio disabled, switching output to HDMI")
|
||||||
|
config.AudioOutputSource = "hdmi"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update USB gadget configuration
|
||||||
|
if err := gadget.UpdateGadgetConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to update gadget config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save configuration
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restart audio if needed
|
||||||
|
if err := startAudio(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to restart audio after USB reconfiguration")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetUsbDevices(usbDevices usbgadget.Devices) error {
|
func rpcSetUsbDevices(usbDevices usbgadget.Devices) error {
|
||||||
|
wasUsbAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio
|
||||||
|
currentDevices := gadget.GetGadgetDevices()
|
||||||
|
|
||||||
|
// Skip reconfiguration if devices haven't changed to avoid HID disruption
|
||||||
|
if currentDevices.Equals(usbDevices) {
|
||||||
|
logger.Debug().Msg("USB devices unchanged, skipping gadget reconfiguration")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
config.UsbDevices = &usbDevices
|
config.UsbDevices = &usbDevices
|
||||||
gadget.SetGadgetDevices(config.UsbDevices)
|
gadget.SetGadgetDevices(config.UsbDevices)
|
||||||
return updateUsbRelatedConfig()
|
|
||||||
|
return updateUsbRelatedConfig(wasUsbAudioEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetUsbDeviceState(device string, enabled bool) error {
|
func rpcSetUsbDeviceState(device string, enabled bool) error {
|
||||||
|
wasUsbAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio
|
||||||
|
currentDevices := gadget.GetGadgetDevices()
|
||||||
|
|
||||||
switch device {
|
switch device {
|
||||||
case "absoluteMouse":
|
case "absoluteMouse":
|
||||||
config.UsbDevices.AbsoluteMouse = enabled
|
config.UsbDevices.AbsoluteMouse = enabled
|
||||||
|
|
@ -868,11 +926,117 @@ func rpcSetUsbDeviceState(device string, enabled bool) error {
|
||||||
config.UsbDevices.Keyboard = enabled
|
config.UsbDevices.Keyboard = enabled
|
||||||
case "massStorage":
|
case "massStorage":
|
||||||
config.UsbDevices.MassStorage = enabled
|
config.UsbDevices.MassStorage = enabled
|
||||||
|
case "audio":
|
||||||
|
config.UsbDevices.Audio = enabled
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid device: %s", device)
|
return fmt.Errorf("invalid device: %s", device)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip reconfiguration if devices haven't changed to avoid HID disruption
|
||||||
|
if currentDevices.Equals(*config.UsbDevices) {
|
||||||
|
logger.Debug().Msg("USB device state unchanged, skipping gadget reconfiguration")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
gadget.SetGadgetDevices(config.UsbDevices)
|
gadget.SetGadgetDevices(config.UsbDevices)
|
||||||
return updateUsbRelatedConfig()
|
return updateUsbRelatedConfig(wasUsbAudioEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetAudioOutputEnabled() (bool, error) {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
return config.AudioOutputEnabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetAudioOutputEnabled(enabled bool) error {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
config.AudioOutputEnabled = enabled
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return SetAudioOutputEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetAudioInputEnabled() (bool, error) {
|
||||||
|
return audioInputEnabled.Load(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetAudioInputEnabled(enabled bool) error {
|
||||||
|
return SetAudioInputEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetAudioOutputSource() (string, error) {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
if config.AudioOutputSource == "" {
|
||||||
|
return "usb", nil
|
||||||
|
}
|
||||||
|
return config.AudioOutputSource, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetAudioOutputSource(source string) error {
|
||||||
|
return SetAudioOutputSource(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioConfigResponse struct {
|
||||||
|
Bitrate int `json:"bitrate"`
|
||||||
|
Complexity int `json:"complexity"`
|
||||||
|
DTXEnabled bool `json:"dtx_enabled"`
|
||||||
|
FECEnabled bool `json:"fec_enabled"`
|
||||||
|
BufferPeriods int `json:"buffer_periods"`
|
||||||
|
PacketLossPerc int `json:"packet_loss_perc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetAudioConfig() (AudioConfigResponse, error) {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
cfg := getAudioConfig()
|
||||||
|
return AudioConfigResponse{
|
||||||
|
Bitrate: int(cfg.Bitrate),
|
||||||
|
Complexity: int(cfg.Complexity),
|
||||||
|
DTXEnabled: cfg.DTXEnabled,
|
||||||
|
FECEnabled: cfg.FECEnabled,
|
||||||
|
BufferPeriods: int(cfg.BufferPeriods),
|
||||||
|
PacketLossPerc: int(cfg.PacketLossPerc),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetAudioConfig(bitrate int, complexity int, dtxEnabled bool, fecEnabled bool, bufferPeriods int, packetLossPerc int) error {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
|
||||||
|
if bitrate < 64 || bitrate > 256 {
|
||||||
|
return fmt.Errorf("bitrate must be between 64 and 256 kbps")
|
||||||
|
}
|
||||||
|
if complexity < 0 || complexity > 10 {
|
||||||
|
return fmt.Errorf("complexity must be between 0 and 10")
|
||||||
|
}
|
||||||
|
if bufferPeriods < 2 || bufferPeriods > 24 {
|
||||||
|
return fmt.Errorf("buffer periods must be between 2 and 24")
|
||||||
|
}
|
||||||
|
if packetLossPerc < 0 || packetLossPerc > 100 {
|
||||||
|
return fmt.Errorf("packet loss percentage must be between 0 and 100")
|
||||||
|
}
|
||||||
|
|
||||||
|
config.AudioBitrate = bitrate
|
||||||
|
config.AudioComplexity = complexity
|
||||||
|
config.AudioDTXEnabled = dtxEnabled
|
||||||
|
config.AudioFECEnabled = fecEnabled
|
||||||
|
config.AudioBufferPeriods = bufferPeriods
|
||||||
|
config.AudioPacketLossPerc = packetLossPerc
|
||||||
|
|
||||||
|
return SaveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcRestartAudioOutput() error {
|
||||||
|
return RestartAudioOutput()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetAudioInputAutoEnable() (bool, error) {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
return config.AudioInputAutoEnable, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetAudioInputAutoEnable(enabled bool) error {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
config.AudioInputAutoEnable = enabled
|
||||||
|
return SaveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetCloudUrl(apiUrl string, appUrl string) error {
|
func rpcSetCloudUrl(apiUrl string, appUrl string) error {
|
||||||
|
|
@ -1117,97 +1281,110 @@ func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacro
|
||||||
}
|
}
|
||||||
|
|
||||||
var rpcHandlers = map[string]RPCHandler{
|
var rpcHandlers = map[string]RPCHandler{
|
||||||
"ping": {Func: rpcPing},
|
"ping": {Func: rpcPing},
|
||||||
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
||||||
"getDeviceID": {Func: rpcGetDeviceID},
|
"getDeviceID": {Func: rpcGetDeviceID},
|
||||||
"deregisterDevice": {Func: rpcDeregisterDevice},
|
"deregisterDevice": {Func: rpcDeregisterDevice},
|
||||||
"getCloudState": {Func: rpcGetCloudState},
|
"getCloudState": {Func: rpcGetCloudState},
|
||||||
"getNetworkState": {Func: rpcGetNetworkState},
|
"getNetworkState": {Func: rpcGetNetworkState},
|
||||||
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
||||||
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
||||||
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
||||||
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
|
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
|
||||||
"getKeyDownState": {Func: rpcGetKeysDownState},
|
"getKeyDownState": {Func: rpcGetKeysDownState},
|
||||||
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
||||||
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
|
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
|
||||||
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
||||||
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
|
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
|
||||||
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
||||||
"getVideoState": {Func: rpcGetVideoState},
|
"getVideoState": {Func: rpcGetVideoState},
|
||||||
"getUSBState": {Func: rpcGetUSBState},
|
"getUSBState": {Func: rpcGetUSBState},
|
||||||
"unmountImage": {Func: rpcUnmountImage},
|
"unmountImage": {Func: rpcUnmountImage},
|
||||||
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
||||||
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
||||||
"getJigglerState": {Func: rpcGetJigglerState},
|
"getJigglerState": {Func: rpcGetJigglerState},
|
||||||
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
|
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
|
||||||
"getJigglerConfig": {Func: rpcGetJigglerConfig},
|
"getJigglerConfig": {Func: rpcGetJigglerConfig},
|
||||||
"getTimezones": {Func: rpcGetTimezones},
|
"getTimezones": {Func: rpcGetTimezones},
|
||||||
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
|
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
|
||||||
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
|
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
|
||||||
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
|
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
|
||||||
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
|
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
|
||||||
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
|
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
|
||||||
"getEDID": {Func: rpcGetEDID},
|
"getEDID": {Func: rpcGetEDID},
|
||||||
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
|
"getDefaultEDID": {Func: rpcGetDefaultEDID},
|
||||||
"getVideoLogStatus": {Func: rpcGetVideoLogStatus},
|
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
|
||||||
"getVideoSleepMode": {Func: rpcGetVideoSleepMode},
|
"getVideoLogStatus": {Func: rpcGetVideoLogStatus},
|
||||||
"setVideoSleepMode": {Func: rpcSetVideoSleepMode, Params: []string{"duration"}},
|
"getVideoSleepMode": {Func: rpcGetVideoSleepMode},
|
||||||
"getDevChannelState": {Func: rpcGetDevChannelState},
|
"setVideoSleepMode": {Func: rpcSetVideoSleepMode, Params: []string{"duration"}},
|
||||||
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
"getDevChannelState": {Func: rpcGetDevChannelState},
|
||||||
"getLocalVersion": {Func: rpcGetLocalVersion},
|
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
||||||
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
"getLocalVersion": {Func: rpcGetLocalVersion},
|
||||||
"checkUpdateComponents": {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}},
|
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
||||||
"getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel},
|
"getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel, Params: []string{"channel"}},
|
||||||
"tryUpdate": {Func: rpcTryUpdate},
|
"checkUpdateComponents": {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}},
|
||||||
"tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}},
|
"tryUpdate": {Func: rpcTryUpdate},
|
||||||
"getDevModeState": {Func: rpcGetDevModeState},
|
"tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}},
|
||||||
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
|
"getDevModeState": {Func: rpcGetDevModeState},
|
||||||
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
|
||||||
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
||||||
"getTLSState": {Func: rpcGetTLSState},
|
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
||||||
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
|
"getTLSState": {Func: rpcGetTLSState},
|
||||||
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
|
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
|
||||||
"getMassStorageMode": {Func: rpcGetMassStorageMode},
|
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
|
||||||
"isUpdatePending": {Func: rpcIsUpdatePending},
|
"getMassStorageMode": {Func: rpcGetMassStorageMode},
|
||||||
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
"isUpdatePending": {Func: rpcIsUpdatePending},
|
||||||
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
||||||
"getUsbConfig": {Func: rpcGetUsbConfig},
|
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
||||||
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
"getUsbConfig": {Func: rpcGetUsbConfig},
|
||||||
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
||||||
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
|
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
||||||
"getStorageSpace": {Func: rpcGetStorageSpace},
|
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
|
||||||
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
"getStorageSpace": {Func: rpcGetStorageSpace},
|
||||||
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
||||||
"listStorageFiles": {Func: rpcListStorageFiles},
|
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
||||||
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
|
"listStorageFiles": {Func: rpcListStorageFiles},
|
||||||
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
|
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
|
||||||
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
|
||||||
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
||||||
"resetConfig": {Func: rpcResetConfig},
|
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
||||||
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
|
"resetConfig": {Func: rpcResetConfig},
|
||||||
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
|
||||||
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
||||||
"getBacklightSettings": {Func: rpcGetBacklightSettings},
|
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
||||||
"getDCPowerState": {Func: rpcGetDCPowerState},
|
"getBacklightSettings": {Func: rpcGetBacklightSettings},
|
||||||
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
|
"getDCPowerState": {Func: rpcGetDCPowerState},
|
||||||
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
|
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
|
||||||
"getActiveExtension": {Func: rpcGetActiveExtension},
|
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
|
||||||
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
|
"getActiveExtension": {Func: rpcGetActiveExtension},
|
||||||
"getATXState": {Func: rpcGetATXState},
|
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
|
||||||
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
|
"getATXState": {Func: rpcGetATXState},
|
||||||
"getSerialSettings": {Func: rpcGetSerialSettings},
|
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
|
||||||
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
|
"getSerialSettings": {Func: rpcGetSerialSettings},
|
||||||
"getUsbDevices": {Func: rpcGetUsbDevices},
|
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
|
||||||
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
|
"getUsbDevices": {Func: rpcGetUsbDevices},
|
||||||
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
|
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
|
||||||
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
|
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
|
||||||
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
|
"getAudioOutputEnabled": {Func: rpcGetAudioOutputEnabled},
|
||||||
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
|
"setAudioOutputEnabled": {Func: rpcSetAudioOutputEnabled, Params: []string{"enabled"}},
|
||||||
"getKeyboardMacros": {Func: getKeyboardMacros},
|
"getAudioInputEnabled": {Func: rpcGetAudioInputEnabled},
|
||||||
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
"setAudioInputEnabled": {Func: rpcSetAudioInputEnabled, Params: []string{"enabled"}},
|
||||||
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
"getAudioOutputSource": {Func: rpcGetAudioOutputSource},
|
||||||
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
|
"setAudioOutputSource": {Func: rpcSetAudioOutputSource, Params: []string{"source"}},
|
||||||
"getFailSafeLogs": {Func: rpcGetFailsafeLogs},
|
"refreshHdmiConnection": {Func: rpcRefreshHdmiConnection},
|
||||||
"getPublicIPAddresses": {Func: rpcGetPublicIPAddresses, Params: []string{"refresh"}},
|
"getAudioConfig": {Func: rpcGetAudioConfig},
|
||||||
"checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses},
|
"setAudioConfig": {Func: rpcSetAudioConfig, Params: []string{"bitrate", "complexity", "dtxEnabled", "fecEnabled", "bufferPeriods", "packetLossPerc"}},
|
||||||
|
"restartAudioOutput": {Func: rpcRestartAudioOutput},
|
||||||
|
"getAudioInputAutoEnable": {Func: rpcGetAudioInputAutoEnable},
|
||||||
|
"setAudioInputAutoEnable": {Func: rpcSetAudioInputAutoEnable, Params: []string{"enabled"}},
|
||||||
|
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
|
||||||
|
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
|
||||||
|
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
|
||||||
|
"getKeyboardMacros": {Func: getKeyboardMacros},
|
||||||
|
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
||||||
|
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
||||||
|
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
|
||||||
|
"getFailSafeLogs": {Func: rpcGetFailsafeLogs},
|
||||||
|
"getPublicIPAddresses": {Func: rpcGetPublicIPAddresses, Params: []string{"refresh"}},
|
||||||
|
"checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
main.go
5
main.go
|
|
@ -58,6 +58,9 @@ func Main() {
|
||||||
initNative(systemVersionLocal, appVersionLocal)
|
initNative(systemVersionLocal, appVersionLocal)
|
||||||
initDisplay()
|
initDisplay()
|
||||||
|
|
||||||
|
initAudio()
|
||||||
|
defer stopAudio()
|
||||||
|
|
||||||
http.DefaultClient.Timeout = 1 * time.Minute
|
http.DefaultClient.Timeout = 1 * time.Minute
|
||||||
|
|
||||||
err = rootcerts.UpdateDefaultTransport()
|
err = rootcerts.UpdateDefaultTransport()
|
||||||
|
|
@ -104,6 +107,7 @@ func Main() {
|
||||||
if err := initImagesFolder(); err != nil {
|
if err := initImagesFolder(); err != nil {
|
||||||
logger.Warn().Err(err).Msg("failed to init images folder")
|
logger.Warn().Err(err).Msg("failed to init images folder")
|
||||||
}
|
}
|
||||||
|
|
||||||
initJiggler()
|
initJiggler()
|
||||||
|
|
||||||
// start video sleep mode timer
|
// start video sleep mode timer
|
||||||
|
|
@ -170,6 +174,7 @@ func Main() {
|
||||||
<-sigs
|
<-sigs
|
||||||
|
|
||||||
logger.Log().Msg("JetKVM Shutting Down")
|
logger.Log().Msg("JetKVM Shutting Down")
|
||||||
|
|
||||||
//if fuseServer != nil {
|
//if fuseServer != nil {
|
||||||
// err := setMassStorageImage(" ")
|
// err := setMassStorageImage(" ")
|
||||||
// if err != nil {
|
// if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ BUILD_IN_DOCKER=${BUILD_IN_DOCKER:-false}
|
||||||
function prepare_docker_build_context() {
|
function prepare_docker_build_context() {
|
||||||
msg_info "▶ Preparing docker build context ..."
|
msg_info "▶ Preparing docker build context ..."
|
||||||
cp .devcontainer/install-deps.sh \
|
cp .devcontainer/install-deps.sh \
|
||||||
|
.devcontainer/install_audio_deps.sh \
|
||||||
go.mod \
|
go.mod \
|
||||||
go.sum \
|
go.sum \
|
||||||
Dockerfile.build \
|
Dockerfile.build \
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,11 @@ EOF
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Always clear Go build caches to prevent stale CGO builds
|
||||||
|
msg_info "▶ Clearing Go build caches"
|
||||||
|
go clean -cache -modcache -testcache -fuzzcache
|
||||||
|
msg_info "✓ Build caches cleared"
|
||||||
|
|
||||||
# Build the development version on the host
|
# Build the development version on the host
|
||||||
# When using `make build_release`, the frontend will be built regardless of the `SKIP_UI_BUILD` flag
|
# When using `make build_release`, the frontend will be built regardless of the `SKIP_UI_BUILD` flag
|
||||||
# check if static/index.html exists
|
# check if static/index.html exists
|
||||||
|
|
@ -260,18 +265,20 @@ fi
|
||||||
if [ "$INSTALL_APP" = true ]
|
if [ "$INSTALL_APP" = true ]
|
||||||
then
|
then
|
||||||
msg_info "▶ Building release binary"
|
msg_info "▶ Building release binary"
|
||||||
|
# Build audio dependencies and release binary
|
||||||
|
do_make build_audio_deps
|
||||||
do_make build_release \
|
do_make build_release \
|
||||||
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
|
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
|
||||||
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
|
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
|
||||||
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
|
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
|
||||||
|
|
||||||
# Copy the binary to the remote host as if we were the OTA updater.
|
# Deploy as OTA update and reboot
|
||||||
sshdev "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
|
sshdev "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
|
||||||
|
|
||||||
# Reboot the device, the new app will be deployed by the startup process.
|
|
||||||
sshdev "reboot"
|
sshdev "reboot"
|
||||||
else
|
else
|
||||||
msg_info "▶ Building development binary"
|
msg_info "▶ Building development binary"
|
||||||
|
# Build audio dependencies and development binary
|
||||||
|
do_make build_audio_deps
|
||||||
do_make build_dev \
|
do_make build_dev \
|
||||||
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
|
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
|
||||||
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
|
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"access_tls_self_signed": "Selvsigneret",
|
"access_tls_self_signed": "Selvsigneret",
|
||||||
"access_tls_updated": "TLS-indstillingerne er blevet opdateret",
|
"access_tls_updated": "TLS-indstillingerne er blevet opdateret",
|
||||||
"access_update_tls_settings": "Opdater TLS-indstillinger",
|
"access_update_tls_settings": "Opdater TLS-indstillinger",
|
||||||
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Forbindelsesstatistik",
|
"action_bar_connection_stats": "Forbindelsesstatistik",
|
||||||
"action_bar_extension": "Udvidelse",
|
"action_bar_extension": "Udvidelse",
|
||||||
"action_bar_fullscreen": "Fuldskærm",
|
"action_bar_fullscreen": "Fuldskærm",
|
||||||
|
|
@ -74,7 +75,6 @@
|
||||||
"advanced_error_update_ssh_key": "Kunne ikke opdatere SSH-nøglen: {error}",
|
"advanced_error_update_ssh_key": "Kunne ikke opdatere SSH-nøglen: {error}",
|
||||||
"advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}",
|
"advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}",
|
||||||
"advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}",
|
"advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}",
|
||||||
"advanced_error_version_update": "Kunne ikke starte versionsopdatering: {error}",
|
|
||||||
"advanced_loopback_only_description": "Begræns webgrænsefladeadgang kun til localhost (127.0.0.1)",
|
"advanced_loopback_only_description": "Begræns webgrænsefladeadgang kun til localhost (127.0.0.1)",
|
||||||
"advanced_loopback_only_title": "Kun loopback-tilstand",
|
"advanced_loopback_only_title": "Kun loopback-tilstand",
|
||||||
"advanced_loopback_warning_before": "Før du aktiverer denne funktion, skal du sikre dig, at du har enten:",
|
"advanced_loopback_warning_before": "Før du aktiverer denne funktion, skal du sikre dig, at du har enten:",
|
||||||
|
|
@ -101,19 +101,6 @@
|
||||||
"advanced_update_ssh_key_button": "Opdater SSH-nøgle",
|
"advanced_update_ssh_key_button": "Opdater SSH-nøgle",
|
||||||
"advanced_usb_emulation_description": "Styr USB-emuleringstilstanden",
|
"advanced_usb_emulation_description": "Styr USB-emuleringstilstanden",
|
||||||
"advanced_usb_emulation_title": "USB-emulering",
|
"advanced_usb_emulation_title": "USB-emulering",
|
||||||
"advanced_version_update_app_label": "App-version",
|
|
||||||
"advanced_version_update_button": "Opdatering til version",
|
|
||||||
"advanced_version_update_description": "Installer en specifik version fra GitHub-udgivelser",
|
|
||||||
"advanced_version_update_github_link": "JetKVM-udgivelsesside",
|
|
||||||
"advanced_version_update_helper": "Find tilgængelige versioner på",
|
|
||||||
"advanced_version_update_reset_config_description": "Nulstil konfigurationen efter opdateringen",
|
|
||||||
"advanced_version_update_reset_config_label": "Nulstil konfiguration",
|
|
||||||
"advanced_version_update_system_label": "Systemversion",
|
|
||||||
"advanced_version_update_target_app": "Kun i appen",
|
|
||||||
"advanced_version_update_target_both": "Både app og system",
|
|
||||||
"advanced_version_update_target_label": "Hvad skal opdateres",
|
|
||||||
"advanced_version_update_target_system": "Kun systemet",
|
|
||||||
"advanced_version_update_title": "Opdatering til specifik version",
|
|
||||||
"already_adopted_new_owner": "Hvis du er den nye ejer, bedes du bede den tidligere ejer om at afregistrere enheden fra sin konto i cloud-dashboardet. Hvis du mener, at dette er en fejl, kan du kontakte vores supportteam for at få hjælp.",
|
"already_adopted_new_owner": "Hvis du er den nye ejer, bedes du bede den tidligere ejer om at afregistrere enheden fra sin konto i cloud-dashboardet. Hvis du mener, at dette er en fejl, kan du kontakte vores supportteam for at få hjælp.",
|
||||||
"already_adopted_other_user": "Denne enhed er i øjeblikket registreret til en anden bruger i vores cloud-dashboard.",
|
"already_adopted_other_user": "Denne enhed er i øjeblikket registreret til en anden bruger i vores cloud-dashboard.",
|
||||||
"already_adopted_return_to_dashboard": "Tilbage til dashboardet",
|
"already_adopted_return_to_dashboard": "Tilbage til dashboardet",
|
||||||
|
|
@ -134,6 +121,50 @@
|
||||||
"atx_power_control_reset_button": "Nulstil",
|
"atx_power_control_reset_button": "Nulstil",
|
||||||
"atx_power_control_send_action_error": "Kunne ikke sende ATX-strømfunktion {action} : {error}",
|
"atx_power_control_send_action_error": "Kunne ikke sende ATX-strømfunktion {action} : {error}",
|
||||||
"atx_power_control_short_power_button": "Kort tryk",
|
"atx_power_control_short_power_button": "Kort tryk",
|
||||||
|
"audio_https_only": "Kun HTTPS",
|
||||||
|
"audio_input_auto_enable_disabled": "Automatisk aktivering af mikrofon deaktiveret",
|
||||||
|
"audio_input_auto_enable_enabled": "Automatisk aktivering af mikrofon aktiveret",
|
||||||
|
"audio_input_failed_disable": "Kunne ikke deaktivere lydindgang: {error}",
|
||||||
|
"audio_input_failed_enable": "Kunne ikke aktivere lydindgang: {error}",
|
||||||
|
"audio_microphone_description": "Mikrofonindgang til mål",
|
||||||
|
"audio_microphone_title": "Mikrofon",
|
||||||
|
"audio_output_disabled": "Lydudgang deaktiveret",
|
||||||
|
"audio_output_enabled": "Lydudgang aktiveret",
|
||||||
|
"audio_output_failed_disable": "Kunne ikke deaktivere lydudgang: {error}",
|
||||||
|
"audio_output_failed_enable": "Kunne ikke aktivere lydudgang: {error}",
|
||||||
|
"audio_popover_description": "Hurtige lydkontroller til højttalere og mikrofon",
|
||||||
|
"audio_popover_title": "Lyd",
|
||||||
|
"audio_settings_applied": "Lydindstillinger anvendt",
|
||||||
|
"audio_settings_apply_button": "Anvend indstillinger",
|
||||||
|
"audio_settings_auto_enable_microphone_description": "Aktiver automatisk browsermikrofon ved tilslutning (ellers skal du aktivere det manuelt ved hver session)",
|
||||||
|
"audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk",
|
||||||
|
"audio_settings_bitrate_description": "Lydkodningsbitrate (højere = bedre kvalitet, mere båndbredde)",
|
||||||
|
"audio_settings_bitrate_title": "Opus Bitrate",
|
||||||
|
"audio_settings_buffer_description": "ALSA bufferstørrelse (højere = mere stabil, mere latens)",
|
||||||
|
"audio_settings_buffer_title": "Bufferperioder",
|
||||||
|
"audio_settings_complexity_description": "Encoder-kompleksitet (0-10, højere = bedre kvalitet, mere CPU)",
|
||||||
|
"audio_settings_complexity_title": "Opus Kompleksitet",
|
||||||
|
"audio_settings_config_updated": "Lydkonfiguration opdateret",
|
||||||
|
"audio_settings_description": "Konfigurer lydindgangs- og lydudgangsindstillinger for din JetKVM-enhed",
|
||||||
|
"audio_settings_dtx_description": "Spar båndbredde under stilhed",
|
||||||
|
"audio_settings_dtx_title": "DTX (Diskontinuerlig Transmission)",
|
||||||
|
"audio_settings_fec_description": "Forbedre lydkvaliteten på tabende forbindelser",
|
||||||
|
"audio_settings_fec_title": "FEC (Fremadrettet Fejlkorrektion)",
|
||||||
|
"audio_settings_hdmi_label": "HDMI",
|
||||||
|
"audio_settings_output_description": "Aktiver eller deaktiver lyd fra fjerncomputeren",
|
||||||
|
"audio_settings_output_source_description": "Vælg lydoptagelsesenheden (HDMI eller USB)",
|
||||||
|
"audio_settings_output_source_failed": "Kunne ikke indstille lydudgangskilde: {error}",
|
||||||
|
"audio_settings_output_source_success": "Lydudgangskilde opdateret. Lyd starter om 30-60 sekunder.",
|
||||||
|
"audio_settings_output_source_title": "Lydudgangskilde",
|
||||||
|
"audio_settings_output_title": "Lydudgang",
|
||||||
|
"audio_settings_packet_loss_description": "FEC overhead-procent (højere = bedre gendannelse, mere båndbredde)",
|
||||||
|
"audio_settings_packet_loss_title": "Pakketabskompensation",
|
||||||
|
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
|
||||||
|
"audio_settings_sample_rate_title": "Samplingsrate",
|
||||||
|
"audio_settings_title": "Lyd",
|
||||||
|
"audio_settings_usb_label": "USB",
|
||||||
|
"audio_speakers_description": "Lyd fra mål til højttalere",
|
||||||
|
"audio_speakers_title": "Højttalere",
|
||||||
"auth_authentication_mode": "Vælg venligst en godkendelsestilstand",
|
"auth_authentication_mode": "Vælg venligst en godkendelsestilstand",
|
||||||
"auth_authentication_mode_error": "Der opstod en fejl under indstilling af godkendelsestilstanden",
|
"auth_authentication_mode_error": "Der opstod en fejl under indstilling af godkendelsestilstanden",
|
||||||
"auth_authentication_mode_invalid": "Ugyldig godkendelsestilstand",
|
"auth_authentication_mode_invalid": "Ugyldig godkendelsestilstand",
|
||||||
|
|
@ -193,7 +224,6 @@
|
||||||
"connection_stats_remote_ip_address": "Fjern IP-adresse",
|
"connection_stats_remote_ip_address": "Fjern IP-adresse",
|
||||||
"connection_stats_remote_ip_address_copy_error": "Kunne ikke kopiere fjern-IP-adresse",
|
"connection_stats_remote_ip_address_copy_error": "Kunne ikke kopiere fjern-IP-adresse",
|
||||||
"connection_stats_remote_ip_address_copy_success": "Fjern IP-adresse { ip } kopieret til udklipsholder",
|
"connection_stats_remote_ip_address_copy_success": "Fjern IP-adresse { ip } kopieret til udklipsholder",
|
||||||
"connection_stats_remote_ip_address_description": "IP-adressen på den eksterne enhed.",
|
|
||||||
"connection_stats_round_trip_time": "Rundturstid",
|
"connection_stats_round_trip_time": "Rundturstid",
|
||||||
"connection_stats_round_trip_time_description": "Rundrejsetid for det aktive ICE-kandidatpar mellem peers.",
|
"connection_stats_round_trip_time_description": "Rundrejsetid for det aktive ICE-kandidatpar mellem peers.",
|
||||||
"connection_stats_sidebar": "Forbindelsesstatistik",
|
"connection_stats_sidebar": "Forbindelsesstatistik",
|
||||||
|
|
@ -259,7 +289,6 @@
|
||||||
"general_auto_update_description": "Opdater automatisk enheden til den nyeste version",
|
"general_auto_update_description": "Opdater automatisk enheden til den nyeste version",
|
||||||
"general_auto_update_error": "Kunne ikke indstille automatisk opdatering: {error}",
|
"general_auto_update_error": "Kunne ikke indstille automatisk opdatering: {error}",
|
||||||
"general_auto_update_title": "Automatisk opdatering",
|
"general_auto_update_title": "Automatisk opdatering",
|
||||||
"general_check_for_stable_updates": "Nedgradering",
|
|
||||||
"general_check_for_updates": "Tjek for opdateringer",
|
"general_check_for_updates": "Tjek for opdateringer",
|
||||||
"general_page_description": "Konfigurer enhedsindstillinger og opdater præferencer",
|
"general_page_description": "Konfigurer enhedsindstillinger og opdater præferencer",
|
||||||
"general_reboot_description": "Vil du fortsætte med at genstarte systemet?",
|
"general_reboot_description": "Vil du fortsætte med at genstarte systemet?",
|
||||||
|
|
@ -280,13 +309,9 @@
|
||||||
"general_update_checking_title": "Søger efter opdateringer…",
|
"general_update_checking_title": "Søger efter opdateringer…",
|
||||||
"general_update_completed_description": "Din enhed er blevet opdateret til den nyeste version. Nyd de nye funktioner og forbedringer!",
|
"general_update_completed_description": "Din enhed er blevet opdateret til den nyeste version. Nyd de nye funktioner og forbedringer!",
|
||||||
"general_update_completed_title": "Opdatering gennemført",
|
"general_update_completed_title": "Opdatering gennemført",
|
||||||
"general_update_downgrade_available_description": "En nedgradering er tilgængelig for at vende tilbage til en tidligere version.",
|
|
||||||
"general_update_downgrade_available_title": "Nedgradering tilgængelig",
|
|
||||||
"general_update_downgrade_button": "Nedgrader nu",
|
|
||||||
"general_update_error_description": "Der opstod en fejl under opdateringen af din enhed. Prøv igen senere.",
|
"general_update_error_description": "Der opstod en fejl under opdateringen af din enhed. Prøv igen senere.",
|
||||||
"general_update_error_details": "Fejldetaljer: {errorMessage}",
|
"general_update_error_details": "Fejldetaljer: {errorMessage}",
|
||||||
"general_update_error_title": "Opdateringsfejl",
|
"general_update_error_title": "Opdateringsfejl",
|
||||||
"general_update_keep_current_button": "Behold den aktuelle version",
|
|
||||||
"general_update_later_button": "Opdater senere",
|
"general_update_later_button": "Opdater senere",
|
||||||
"general_update_now_button": "Opdater nu",
|
"general_update_now_button": "Opdater nu",
|
||||||
"general_update_rebooting": "Genstarter for at fuldføre opdateringen…",
|
"general_update_rebooting": "Genstarter for at fuldføre opdateringen…",
|
||||||
|
|
@ -302,7 +327,6 @@
|
||||||
"general_update_up_to_date_title": "Systemet er opdateret",
|
"general_update_up_to_date_title": "Systemet er opdateret",
|
||||||
"general_update_updating_description": "Sluk ikke enheden. Denne proces kan tage et par minutter.",
|
"general_update_updating_description": "Sluk ikke enheden. Denne proces kan tage et par minutter.",
|
||||||
"general_update_updating_title": "Opdatering af din enhed",
|
"general_update_updating_title": "Opdatering af din enhed",
|
||||||
"general_update_will_disable_auto_update_description": "Du er ved at ændre din enhedsversion manuelt. Automatisk opdatering vil blive deaktiveret, når opdateringen er fuldført, for at forhindre utilsigtede opdateringer.",
|
|
||||||
"getting_remote_session_description": "Henter beskrivelse af fjernsessionsforsøg {attempt}",
|
"getting_remote_session_description": "Henter beskrivelse af fjernsessionsforsøg {attempt}",
|
||||||
"hardware_backlight_settings_error": "Kunne ikke indstille baggrundsbelysningsindstillinger: {error}",
|
"hardware_backlight_settings_error": "Kunne ikke indstille baggrundsbelysningsindstillinger: {error}",
|
||||||
"hardware_backlight_settings_get_error": "Kunne ikke hente indstillinger for baggrundsbelysning: {error}",
|
"hardware_backlight_settings_get_error": "Kunne ikke hente indstillinger for baggrundsbelysning: {error}",
|
||||||
|
|
@ -817,6 +841,8 @@
|
||||||
"usb_device_description": "USB-enheder, der skal emuleres på målcomputeren",
|
"usb_device_description": "USB-enheder, der skal emuleres på målcomputeren",
|
||||||
"usb_device_enable_absolute_mouse_description": "Aktivér absolut mus (markør)",
|
"usb_device_enable_absolute_mouse_description": "Aktivér absolut mus (markør)",
|
||||||
"usb_device_enable_absolute_mouse_title": "Aktivér absolut mus (markør)",
|
"usb_device_enable_absolute_mouse_title": "Aktivér absolut mus (markør)",
|
||||||
|
"usb_device_enable_audio_description": "Enable bidirectional audio",
|
||||||
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Aktivér tastatur",
|
"usb_device_enable_keyboard_description": "Aktivér tastatur",
|
||||||
"usb_device_enable_keyboard_title": "Aktivér tastatur",
|
"usb_device_enable_keyboard_title": "Aktivér tastatur",
|
||||||
"usb_device_enable_mass_storage_description": "Nogle gange skal det muligvis deaktiveres for at forhindre problemer med bestemte enheder.",
|
"usb_device_enable_mass_storage_description": "Nogle gange skal det muligvis deaktiveres for at forhindre problemer med bestemte enheder.",
|
||||||
|
|
@ -826,6 +852,7 @@
|
||||||
"usb_device_failed_load": "Kunne ikke indlæse USB-enheder: {error}",
|
"usb_device_failed_load": "Kunne ikke indlæse USB-enheder: {error}",
|
||||||
"usb_device_failed_set": "Kunne ikke indstille USB-enheder: {error}",
|
"usb_device_failed_set": "Kunne ikke indstille USB-enheder: {error}",
|
||||||
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, mus og masselagring",
|
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, mus og masselagring",
|
||||||
|
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
|
||||||
"usb_device_keyboard_only": "Kun tastatur",
|
"usb_device_keyboard_only": "Kun tastatur",
|
||||||
"usb_device_restore_default": "Gendan til standard",
|
"usb_device_restore_default": "Gendan til standard",
|
||||||
"usb_device_title": "USB-enhed",
|
"usb_device_title": "USB-enhed",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"access_tls_self_signed": "Selbstsigniert",
|
"access_tls_self_signed": "Selbstsigniert",
|
||||||
"access_tls_updated": "TLS-Einstellungen erfolgreich aktualisiert",
|
"access_tls_updated": "TLS-Einstellungen erfolgreich aktualisiert",
|
||||||
"access_update_tls_settings": "TLS-Einstellungen aktualisieren",
|
"access_update_tls_settings": "TLS-Einstellungen aktualisieren",
|
||||||
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Verbindungsstatistiken",
|
"action_bar_connection_stats": "Verbindungsstatistiken",
|
||||||
"action_bar_extension": "Erweiterung",
|
"action_bar_extension": "Erweiterung",
|
||||||
"action_bar_fullscreen": "Vollbild",
|
"action_bar_fullscreen": "Vollbild",
|
||||||
|
|
@ -74,7 +75,6 @@
|
||||||
"advanced_error_update_ssh_key": "SSH-Schlüssel konnte nicht aktualisiert werden: {error}",
|
"advanced_error_update_ssh_key": "SSH-Schlüssel konnte nicht aktualisiert werden: {error}",
|
||||||
"advanced_error_usb_emulation_disable": "USB-Emulation konnte nicht deaktiviert werden: {error}",
|
"advanced_error_usb_emulation_disable": "USB-Emulation konnte nicht deaktiviert werden: {error}",
|
||||||
"advanced_error_usb_emulation_enable": "USB-Emulation konnte nicht aktiviert werden: {error}",
|
"advanced_error_usb_emulation_enable": "USB-Emulation konnte nicht aktiviert werden: {error}",
|
||||||
"advanced_error_version_update": "Versionsaktualisierung konnte nicht initiiert werden: {error}",
|
|
||||||
"advanced_loopback_only_description": "Beschränken Sie den Zugriff auf die Weboberfläche nur auf den lokalen Host (127.0.0.1).",
|
"advanced_loopback_only_description": "Beschränken Sie den Zugriff auf die Weboberfläche nur auf den lokalen Host (127.0.0.1).",
|
||||||
"advanced_loopback_only_title": "Nur-Loopback-Modus",
|
"advanced_loopback_only_title": "Nur-Loopback-Modus",
|
||||||
"advanced_loopback_warning_before": "Bevor Sie diese Funktion aktivieren, stellen Sie sicher, dass Sie über Folgendes verfügen:",
|
"advanced_loopback_warning_before": "Bevor Sie diese Funktion aktivieren, stellen Sie sicher, dass Sie über Folgendes verfügen:",
|
||||||
|
|
@ -101,19 +101,6 @@
|
||||||
"advanced_update_ssh_key_button": "SSH-Schlüssel aktualisieren",
|
"advanced_update_ssh_key_button": "SSH-Schlüssel aktualisieren",
|
||||||
"advanced_usb_emulation_description": "Steuern des USB-Emulationsstatus",
|
"advanced_usb_emulation_description": "Steuern des USB-Emulationsstatus",
|
||||||
"advanced_usb_emulation_title": "USB-Emulation",
|
"advanced_usb_emulation_title": "USB-Emulation",
|
||||||
"advanced_version_update_app_label": "App-Version",
|
|
||||||
"advanced_version_update_button": "Aktualisierung auf Version",
|
|
||||||
"advanced_version_update_description": "Installieren Sie eine bestimmte Version aus den GitHub-Releases.",
|
|
||||||
"advanced_version_update_github_link": "JetKVM-Releases-Seite",
|
|
||||||
"advanced_version_update_helper": "Finden Sie verfügbare Versionen auf der",
|
|
||||||
"advanced_version_update_reset_config_description": "Konfiguration nach dem Update zurücksetzen",
|
|
||||||
"advanced_version_update_reset_config_label": "Konfiguration zurücksetzen",
|
|
||||||
"advanced_version_update_system_label": "Systemversion",
|
|
||||||
"advanced_version_update_target_app": "Nur App",
|
|
||||||
"advanced_version_update_target_both": "Sowohl App als auch System",
|
|
||||||
"advanced_version_update_target_label": "Was sollte aktualisiert werden?",
|
|
||||||
"advanced_version_update_target_system": "System nur",
|
|
||||||
"advanced_version_update_title": "Aktualisierung auf eine bestimmte Version",
|
|
||||||
"already_adopted_new_owner": "Wenn Sie der neue Besitzer sind, bitten Sie den Vorbesitzer, das Gerät im Cloud-Dashboard von seinem Konto abzumelden. Wenn Sie glauben, dass dies ein Fehler ist, wenden Sie sich an unser Support-Team.",
|
"already_adopted_new_owner": "Wenn Sie der neue Besitzer sind, bitten Sie den Vorbesitzer, das Gerät im Cloud-Dashboard von seinem Konto abzumelden. Wenn Sie glauben, dass dies ein Fehler ist, wenden Sie sich an unser Support-Team.",
|
||||||
"already_adopted_other_user": "Dieses Gerät ist derzeit in unserem Cloud-Dashboard auf einen anderen Benutzer registriert.",
|
"already_adopted_other_user": "Dieses Gerät ist derzeit in unserem Cloud-Dashboard auf einen anderen Benutzer registriert.",
|
||||||
"already_adopted_return_to_dashboard": "Zurück zum Dashboard",
|
"already_adopted_return_to_dashboard": "Zurück zum Dashboard",
|
||||||
|
|
@ -134,6 +121,50 @@
|
||||||
"atx_power_control_reset_button": "Reset-Taste",
|
"atx_power_control_reset_button": "Reset-Taste",
|
||||||
"atx_power_control_send_action_error": "ATX-Stromversorgungsaktion {action} konnte nicht gesendet werden: {error}",
|
"atx_power_control_send_action_error": "ATX-Stromversorgungsaktion {action} konnte nicht gesendet werden: {error}",
|
||||||
"atx_power_control_short_power_button": "Kurzes Drücken",
|
"atx_power_control_short_power_button": "Kurzes Drücken",
|
||||||
|
"audio_https_only": "Nur HTTPS",
|
||||||
|
"audio_input_auto_enable_disabled": "Automatische Mikrofonaktivierung deaktiviert",
|
||||||
|
"audio_input_auto_enable_enabled": "Automatische Mikrofonaktivierung aktiviert",
|
||||||
|
"audio_input_failed_disable": "Fehler beim Deaktivieren des Audioeingangs: {error}",
|
||||||
|
"audio_input_failed_enable": "Fehler beim Aktivieren des Audioeingangs: {error}",
|
||||||
|
"audio_microphone_description": "Mikrofoneingang zum Ziel",
|
||||||
|
"audio_microphone_title": "Mikrofon",
|
||||||
|
"audio_output_disabled": "Audioausgang deaktiviert",
|
||||||
|
"audio_output_enabled": "Audioausgang aktiviert",
|
||||||
|
"audio_output_failed_disable": "Fehler beim Deaktivieren des Audioausgangs: {error}",
|
||||||
|
"audio_output_failed_enable": "Fehler beim Aktivieren des Audioausgangs: {error}",
|
||||||
|
"audio_popover_description": "Schnelle Audiosteuerung für Lautsprecher und Mikrofon",
|
||||||
|
"audio_popover_title": "Audio",
|
||||||
|
"audio_settings_applied": "Audioeinstellungen angewendet",
|
||||||
|
"audio_settings_apply_button": "Einstellungen anwenden",
|
||||||
|
"audio_settings_auto_enable_microphone_description": "Browser-Mikrofon beim Verbinden automatisch aktivieren (andernfalls müssen Sie es in jeder Sitzung manuell aktivieren)",
|
||||||
|
"audio_settings_auto_enable_microphone_title": "Mikrofon automatisch aktivieren",
|
||||||
|
"audio_settings_bitrate_description": "Audio-Codierungsbitrate (höher = bessere Qualität, mehr Bandbreite)",
|
||||||
|
"audio_settings_bitrate_title": "Opus Bitrate",
|
||||||
|
"audio_settings_buffer_description": "ALSA-Puffergröße (höher = stabiler, mehr Latenz)",
|
||||||
|
"audio_settings_buffer_title": "Pufferperioden",
|
||||||
|
"audio_settings_complexity_description": "Encoder-Komplexität (0-10, höher = bessere Qualität, mehr CPU)",
|
||||||
|
"audio_settings_complexity_title": "Opus Komplexität",
|
||||||
|
"audio_settings_config_updated": "Audiokonfiguration aktualisiert",
|
||||||
|
"audio_settings_description": "Konfigurieren Sie Audio-Eingangs- und Ausgangseinstellungen für Ihr JetKVM-Gerät",
|
||||||
|
"audio_settings_dtx_description": "Bandbreite während Stille sparen",
|
||||||
|
"audio_settings_dtx_title": "DTX (Discontinuous Transmission)",
|
||||||
|
"audio_settings_fec_description": "Audioqualität bei verlustbehafteten Verbindungen verbessern",
|
||||||
|
"audio_settings_fec_title": "FEC (Forward Error Correction)",
|
||||||
|
"audio_settings_hdmi_label": "HDMI",
|
||||||
|
"audio_settings_output_description": "Audio vom entfernten Computer aktivieren oder deaktivieren",
|
||||||
|
"audio_settings_output_source_description": "Wählen Sie das Audioaufnahmegerät (HDMI oder USB)",
|
||||||
|
"audio_settings_output_source_failed": "Fehler beim Festlegen der Audioausgabequelle: {error}",
|
||||||
|
"audio_settings_output_source_success": "Audioausgabequelle aktualisiert. Audio startet in 30-60 Sekunden.",
|
||||||
|
"audio_settings_output_source_title": "Audioausgabequelle",
|
||||||
|
"audio_settings_output_title": "Audioausgang",
|
||||||
|
"audio_settings_packet_loss_description": "FEC-Overhead-Prozentsatz (höher = bessere Wiederherstellung, mehr Bandbreite)",
|
||||||
|
"audio_settings_packet_loss_title": "Paketverlust-Kompensation",
|
||||||
|
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
|
||||||
|
"audio_settings_sample_rate_title": "Abtastrate",
|
||||||
|
"audio_settings_title": "Audio",
|
||||||
|
"audio_settings_usb_label": "USB",
|
||||||
|
"audio_speakers_description": "Audio vom Ziel zu Lautsprechern",
|
||||||
|
"audio_speakers_title": "Lautsprecher",
|
||||||
"auth_authentication_mode": "Bitte wählen Sie einen Authentifizierungsmodus",
|
"auth_authentication_mode": "Bitte wählen Sie einen Authentifizierungsmodus",
|
||||||
"auth_authentication_mode_error": "Beim Einstellen des Authentifizierungsmodus ist ein Fehler aufgetreten",
|
"auth_authentication_mode_error": "Beim Einstellen des Authentifizierungsmodus ist ein Fehler aufgetreten",
|
||||||
"auth_authentication_mode_invalid": "Ungültiger Authentifizierungsmodus",
|
"auth_authentication_mode_invalid": "Ungültiger Authentifizierungsmodus",
|
||||||
|
|
@ -193,7 +224,6 @@
|
||||||
"connection_stats_remote_ip_address": "Remote-IP-Adresse",
|
"connection_stats_remote_ip_address": "Remote-IP-Adresse",
|
||||||
"connection_stats_remote_ip_address_copy_error": "Fehler beim Kopieren der Remote-IP-Adresse",
|
"connection_stats_remote_ip_address_copy_error": "Fehler beim Kopieren der Remote-IP-Adresse",
|
||||||
"connection_stats_remote_ip_address_copy_success": "Remote-IP-Adresse { ip } in die Zwischenablage kopiert",
|
"connection_stats_remote_ip_address_copy_success": "Remote-IP-Adresse { ip } in die Zwischenablage kopiert",
|
||||||
"connection_stats_remote_ip_address_description": "Die IP-Adresse des Remote-Geräts.",
|
|
||||||
"connection_stats_round_trip_time": "Round-Trip-Zeit",
|
"connection_stats_round_trip_time": "Round-Trip-Zeit",
|
||||||
"connection_stats_round_trip_time_description": "Roundtrip-Zeit für das aktive ICE-Kandidatenpaar zwischen Peers.",
|
"connection_stats_round_trip_time_description": "Roundtrip-Zeit für das aktive ICE-Kandidatenpaar zwischen Peers.",
|
||||||
"connection_stats_sidebar": "Verbindungsstatistiken",
|
"connection_stats_sidebar": "Verbindungsstatistiken",
|
||||||
|
|
@ -259,7 +289,6 @@
|
||||||
"general_auto_update_description": "Aktualisieren Sie das Gerät automatisch auf die neueste Version",
|
"general_auto_update_description": "Aktualisieren Sie das Gerät automatisch auf die neueste Version",
|
||||||
"general_auto_update_error": "Automatische Aktualisierung konnte nicht eingestellt werden: {error}",
|
"general_auto_update_error": "Automatische Aktualisierung konnte nicht eingestellt werden: {error}",
|
||||||
"general_auto_update_title": "Automatische Aktualisierung",
|
"general_auto_update_title": "Automatische Aktualisierung",
|
||||||
"general_check_for_stable_updates": "Herabstufung",
|
|
||||||
"general_check_for_updates": "Nach Updates suchen",
|
"general_check_for_updates": "Nach Updates suchen",
|
||||||
"general_page_description": "Geräteeinstellungen konfigurieren und Voreinstellungen aktualisieren",
|
"general_page_description": "Geräteeinstellungen konfigurieren und Voreinstellungen aktualisieren",
|
||||||
"general_reboot_description": "Möchten Sie mit dem Neustart des Systems fortfahren?",
|
"general_reboot_description": "Möchten Sie mit dem Neustart des Systems fortfahren?",
|
||||||
|
|
@ -280,13 +309,9 @@
|
||||||
"general_update_checking_title": "Suche nach Updates…",
|
"general_update_checking_title": "Suche nach Updates…",
|
||||||
"general_update_completed_description": "Ihr Gerät wurde erfolgreich auf die neueste Version aktualisiert. Viel Spaß mit den neuen Funktionen und Verbesserungen!",
|
"general_update_completed_description": "Ihr Gerät wurde erfolgreich auf die neueste Version aktualisiert. Viel Spaß mit den neuen Funktionen und Verbesserungen!",
|
||||||
"general_update_completed_title": "Update erfolgreich abgeschlossen",
|
"general_update_completed_title": "Update erfolgreich abgeschlossen",
|
||||||
"general_update_downgrade_available_description": "Es besteht die Möglichkeit, auf eine frühere Version zurückzukehren.",
|
|
||||||
"general_update_downgrade_available_title": "Downgrade verfügbar",
|
|
||||||
"general_update_downgrade_button": "Jetzt downgraden",
|
|
||||||
"general_update_error_description": "Beim Aktualisieren Ihres Geräts ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal.",
|
"general_update_error_description": "Beim Aktualisieren Ihres Geräts ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal.",
|
||||||
"general_update_error_details": "Fehlerdetails: {errorMessage}",
|
"general_update_error_details": "Fehlerdetails: {errorMessage}",
|
||||||
"general_update_error_title": "Aktualisierungsfehler",
|
"general_update_error_title": "Aktualisierungsfehler",
|
||||||
"general_update_keep_current_button": "Aktuelle Version beibehalten",
|
|
||||||
"general_update_later_button": "Später",
|
"general_update_later_button": "Später",
|
||||||
"general_update_now_button": "Jetzt aktualisieren",
|
"general_update_now_button": "Jetzt aktualisieren",
|
||||||
"general_update_rebooting": "Neustart zum Abschließen des Updates …",
|
"general_update_rebooting": "Neustart zum Abschließen des Updates …",
|
||||||
|
|
@ -302,7 +327,6 @@
|
||||||
"general_update_up_to_date_title": "Das System ist auf dem neuesten Stand",
|
"general_update_up_to_date_title": "Das System ist auf dem neuesten Stand",
|
||||||
"general_update_updating_description": "Bitte schalten Sie Ihr Gerät nicht aus. Dieser Vorgang kann einige Minuten dauern.",
|
"general_update_updating_description": "Bitte schalten Sie Ihr Gerät nicht aus. Dieser Vorgang kann einige Minuten dauern.",
|
||||||
"general_update_updating_title": "Aktualisieren Ihres Geräts",
|
"general_update_updating_title": "Aktualisieren Ihres Geräts",
|
||||||
"general_update_will_disable_auto_update_description": "Sie sind im Begriff, die Version Ihres Geräts manuell zu ändern. Die automatische Aktualisierung wird nach Abschluss der Aktualisierung deaktiviert, um versehentliche Updates zu verhindern.",
|
|
||||||
"getting_remote_session_description": "Versuch, eine Beschreibung der Remote-Sitzung abzurufen {attempt}",
|
"getting_remote_session_description": "Versuch, eine Beschreibung der Remote-Sitzung abzurufen {attempt}",
|
||||||
"hardware_backlight_settings_error": "Fehler beim Festlegen der Hintergrundbeleuchtungseinstellungen: {error}",
|
"hardware_backlight_settings_error": "Fehler beim Festlegen der Hintergrundbeleuchtungseinstellungen: {error}",
|
||||||
"hardware_backlight_settings_get_error": "Die Einstellungen für die Hintergrundbeleuchtung konnten nicht abgerufen werden: {error}",
|
"hardware_backlight_settings_get_error": "Die Einstellungen für die Hintergrundbeleuchtung konnten nicht abgerufen werden: {error}",
|
||||||
|
|
@ -817,6 +841,8 @@
|
||||||
"usb_device_description": "USB-Geräte zum Emulieren auf dem Zielcomputer",
|
"usb_device_description": "USB-Geräte zum Emulieren auf dem Zielcomputer",
|
||||||
"usb_device_enable_absolute_mouse_description": "Absolute Maus (Zeiger) aktivieren",
|
"usb_device_enable_absolute_mouse_description": "Absolute Maus (Zeiger) aktivieren",
|
||||||
"usb_device_enable_absolute_mouse_title": "Absolute Maus (Zeiger) aktivieren",
|
"usb_device_enable_absolute_mouse_title": "Absolute Maus (Zeiger) aktivieren",
|
||||||
|
"usb_device_enable_audio_description": "Enable bidirectional audio",
|
||||||
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Tastatur aktivieren",
|
"usb_device_enable_keyboard_description": "Tastatur aktivieren",
|
||||||
"usb_device_enable_keyboard_title": "Tastatur aktivieren",
|
"usb_device_enable_keyboard_title": "Tastatur aktivieren",
|
||||||
"usb_device_enable_mass_storage_description": "Manchmal muss es möglicherweise deaktiviert werden, um Probleme mit bestimmten Geräten zu vermeiden",
|
"usb_device_enable_mass_storage_description": "Manchmal muss es möglicherweise deaktiviert werden, um Probleme mit bestimmten Geräten zu vermeiden",
|
||||||
|
|
@ -826,6 +852,7 @@
|
||||||
"usb_device_failed_load": "USB-Geräte konnten nicht geladen werden: {error}",
|
"usb_device_failed_load": "USB-Geräte konnten nicht geladen werden: {error}",
|
||||||
"usb_device_failed_set": "Fehler beim Festlegen der USB-Geräte: {error}",
|
"usb_device_failed_set": "Fehler beim Festlegen der USB-Geräte: {error}",
|
||||||
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, Maus und Massenspeicher",
|
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, Maus und Massenspeicher",
|
||||||
|
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
|
||||||
"usb_device_keyboard_only": "Nur Tastatur",
|
"usb_device_keyboard_only": "Nur Tastatur",
|
||||||
"usb_device_restore_default": "Auf Standard zurücksetzen",
|
"usb_device_restore_default": "Auf Standard zurücksetzen",
|
||||||
"usb_device_title": "USB-Gerät",
|
"usb_device_title": "USB-Gerät",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"access_tls_self_signed": "Self-signed",
|
"access_tls_self_signed": "Self-signed",
|
||||||
"access_tls_updated": "TLS settings updated successfully",
|
"access_tls_updated": "TLS settings updated successfully",
|
||||||
"access_update_tls_settings": "Update TLS Settings",
|
"access_update_tls_settings": "Update TLS Settings",
|
||||||
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Connection Stats",
|
"action_bar_connection_stats": "Connection Stats",
|
||||||
"action_bar_extension": "Extension",
|
"action_bar_extension": "Extension",
|
||||||
"action_bar_fullscreen": "Fullscreen",
|
"action_bar_fullscreen": "Fullscreen",
|
||||||
|
|
@ -134,6 +135,53 @@
|
||||||
"atx_power_control_reset_button": "Reset",
|
"atx_power_control_reset_button": "Reset",
|
||||||
"atx_power_control_send_action_error": "Failed to send ATX power action {action}: {error}",
|
"atx_power_control_send_action_error": "Failed to send ATX power action {action}: {error}",
|
||||||
"atx_power_control_short_power_button": "Short Press",
|
"atx_power_control_short_power_button": "Short Press",
|
||||||
|
"audio_https_only": "HTTPS only",
|
||||||
|
"audio_input_auto_enable_disabled": "Auto-enable microphone disabled",
|
||||||
|
"audio_input_auto_enable_enabled": "Auto-enable microphone enabled",
|
||||||
|
"audio_input_failed_disable": "Failed to disable audio input: {error}",
|
||||||
|
"audio_input_failed_enable": "Failed to enable audio input: {error}",
|
||||||
|
"audio_microphone_description": "Microphone input to target",
|
||||||
|
"audio_microphone_title": "Microphone",
|
||||||
|
"audio_output_disabled": "Audio output disabled",
|
||||||
|
"audio_output_enabled": "Audio output enabled",
|
||||||
|
"audio_output_failed_disable": "Failed to disable audio output: {error}",
|
||||||
|
"audio_output_failed_enable": "Failed to enable audio output: {error}",
|
||||||
|
"audio_popover_description": "Quick audio controls for speakers and microphone",
|
||||||
|
"audio_popover_title": "Audio",
|
||||||
|
"audio_settings_applied": "Audio settings applied",
|
||||||
|
"audio_settings_apply_button": "Apply Settings",
|
||||||
|
"audio_settings_auto_enable_microphone_description": "Automatically enable browser microphone when connecting (otherwise you must manually enable each session)",
|
||||||
|
"audio_settings_auto_enable_microphone_title": "Auto-enable Microphone",
|
||||||
|
"audio_settings_bitrate_description": "Audio encoding bitrate (higher = better quality, more bandwidth)",
|
||||||
|
"audio_settings_bitrate_title": "Opus Bitrate",
|
||||||
|
"audio_settings_buffer_description": "ALSA buffer size (higher = more stable, more latency)",
|
||||||
|
"audio_settings_buffer_title": "Buffer Periods",
|
||||||
|
"audio_settings_complexity_description": "Encoder complexity (0-10, higher = better quality, more CPU)",
|
||||||
|
"audio_settings_complexity_title": "Opus Complexity",
|
||||||
|
"audio_settings_config_updated": "Audio configuration updated",
|
||||||
|
"audio_settings_default_lan_suffix": " (default - LAN)",
|
||||||
|
"audio_settings_default_suffix": " (default)",
|
||||||
|
"audio_settings_description": "Configure audio input and output settings for your JetKVM device",
|
||||||
|
"audio_settings_dtx_description": "Save bandwidth during silence",
|
||||||
|
"audio_settings_dtx_title": "DTX (Discontinuous Transmission)",
|
||||||
|
"audio_settings_fec_description": "Improve audio quality on lossy connections",
|
||||||
|
"audio_settings_fec_title": "FEC (Forward Error Correction)",
|
||||||
|
"audio_settings_hdmi_label": "HDMI",
|
||||||
|
"audio_settings_no_compensation_suffix": " (no compensation)",
|
||||||
|
"audio_settings_output_description": "Enable or disable audio from the remote computer",
|
||||||
|
"audio_settings_output_source_description": "Select the audio capture device (HDMI or USB)",
|
||||||
|
"audio_settings_output_source_failed": "Failed to set audio output source: {error}",
|
||||||
|
"audio_settings_output_source_success": "Audio output source updated. Audio will start in 30-60 seconds.",
|
||||||
|
"audio_settings_output_source_title": "Audio Output Source",
|
||||||
|
"audio_settings_output_title": "Audio Output",
|
||||||
|
"audio_settings_packet_loss_description": "FEC overhead percentage (higher = better recovery, more bandwidth)",
|
||||||
|
"audio_settings_packet_loss_title": "Packet Loss Compensation",
|
||||||
|
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
|
||||||
|
"audio_settings_sample_rate_title": "Sample Rate",
|
||||||
|
"audio_settings_title": "Audio",
|
||||||
|
"audio_settings_usb_label": "USB",
|
||||||
|
"audio_speakers_description": "Audio from target to speakers",
|
||||||
|
"audio_speakers_title": "Speakers",
|
||||||
"auth_authentication_mode": "Please select an authentication mode",
|
"auth_authentication_mode": "Please select an authentication mode",
|
||||||
"auth_authentication_mode_error": "An error occurred while setting the authentication mode",
|
"auth_authentication_mode_error": "An error occurred while setting the authentication mode",
|
||||||
"auth_authentication_mode_invalid": "Invalid authentication mode",
|
"auth_authentication_mode_invalid": "Invalid authentication mode",
|
||||||
|
|
@ -817,6 +865,8 @@
|
||||||
"usb_device_description": "USB devices to emulate on the target computer",
|
"usb_device_description": "USB devices to emulate on the target computer",
|
||||||
"usb_device_enable_absolute_mouse_description": "Enable Absolute Mouse (Pointer)",
|
"usb_device_enable_absolute_mouse_description": "Enable Absolute Mouse (Pointer)",
|
||||||
"usb_device_enable_absolute_mouse_title": "Enable Absolute Mouse (Pointer)",
|
"usb_device_enable_absolute_mouse_title": "Enable Absolute Mouse (Pointer)",
|
||||||
|
"usb_device_enable_audio_description": "Enable bidirectional audio",
|
||||||
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Enable Keyboard",
|
"usb_device_enable_keyboard_description": "Enable Keyboard",
|
||||||
"usb_device_enable_keyboard_title": "Enable Keyboard",
|
"usb_device_enable_keyboard_title": "Enable Keyboard",
|
||||||
"usb_device_enable_mass_storage_description": "Sometimes it might need to be disabled to prevent issues with certain devices",
|
"usb_device_enable_mass_storage_description": "Sometimes it might need to be disabled to prevent issues with certain devices",
|
||||||
|
|
@ -826,6 +876,7 @@
|
||||||
"usb_device_failed_load": "Failed to load USB devices: {error}",
|
"usb_device_failed_load": "Failed to load USB devices: {error}",
|
||||||
"usb_device_failed_set": "Failed to set USB devices: {error}",
|
"usb_device_failed_set": "Failed to set USB devices: {error}",
|
||||||
"usb_device_keyboard_mouse_and_mass_storage": "Keyboard, Mouse and Mass Storage",
|
"usb_device_keyboard_mouse_and_mass_storage": "Keyboard, Mouse and Mass Storage",
|
||||||
|
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
|
||||||
"usb_device_keyboard_only": "Keyboard Only",
|
"usb_device_keyboard_only": "Keyboard Only",
|
||||||
"usb_device_restore_default": "Restore to Default",
|
"usb_device_restore_default": "Restore to Default",
|
||||||
"usb_device_title": "USB Device",
|
"usb_device_title": "USB Device",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"access_tls_self_signed": "Autofirmado",
|
"access_tls_self_signed": "Autofirmado",
|
||||||
"access_tls_updated": "La configuración de TLS se actualizó correctamente",
|
"access_tls_updated": "La configuración de TLS se actualizó correctamente",
|
||||||
"access_update_tls_settings": "Actualizar la configuración de TLS",
|
"access_update_tls_settings": "Actualizar la configuración de TLS",
|
||||||
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Estadísticas de conexión",
|
"action_bar_connection_stats": "Estadísticas de conexión",
|
||||||
"action_bar_extension": "Extensión",
|
"action_bar_extension": "Extensión",
|
||||||
"action_bar_fullscreen": "Pantalla completa",
|
"action_bar_fullscreen": "Pantalla completa",
|
||||||
|
|
@ -74,7 +75,6 @@
|
||||||
"advanced_error_update_ssh_key": "No se pudo actualizar la clave SSH: {error}",
|
"advanced_error_update_ssh_key": "No se pudo actualizar la clave SSH: {error}",
|
||||||
"advanced_error_usb_emulation_disable": "No se pudo deshabilitar la emulación USB: {error}",
|
"advanced_error_usb_emulation_disable": "No se pudo deshabilitar la emulación USB: {error}",
|
||||||
"advanced_error_usb_emulation_enable": "No se pudo habilitar la emulación USB: {error}",
|
"advanced_error_usb_emulation_enable": "No se pudo habilitar la emulación USB: {error}",
|
||||||
"advanced_error_version_update": "Error al iniciar la actualización de versión: {error}",
|
|
||||||
"advanced_loopback_only_description": "Restringir el acceso a la interfaz web solo al host local (127.0.0.1)",
|
"advanced_loopback_only_description": "Restringir el acceso a la interfaz web solo al host local (127.0.0.1)",
|
||||||
"advanced_loopback_only_title": "Modo de solo bucle invertido",
|
"advanced_loopback_only_title": "Modo de solo bucle invertido",
|
||||||
"advanced_loopback_warning_before": "Antes de habilitar esta función, asegúrese de tener:",
|
"advanced_loopback_warning_before": "Antes de habilitar esta función, asegúrese de tener:",
|
||||||
|
|
@ -101,19 +101,6 @@
|
||||||
"advanced_update_ssh_key_button": "Actualizar clave SSH",
|
"advanced_update_ssh_key_button": "Actualizar clave SSH",
|
||||||
"advanced_usb_emulation_description": "Controlar el estado de emulación USB",
|
"advanced_usb_emulation_description": "Controlar el estado de emulación USB",
|
||||||
"advanced_usb_emulation_title": "Emulación USB",
|
"advanced_usb_emulation_title": "Emulación USB",
|
||||||
"advanced_version_update_app_label": "Versión de la aplicación",
|
|
||||||
"advanced_version_update_button": "Actualización a la versión",
|
|
||||||
"advanced_version_update_description": "Instala una versión específica desde las versiones de GitHub.",
|
|
||||||
"advanced_version_update_github_link": "Página de lanzamientos de JetKVM",
|
|
||||||
"advanced_version_update_helper": "Encuentra las versiones disponibles en el",
|
|
||||||
"advanced_version_update_reset_config_description": "Restablecer la configuración después de la actualización",
|
|
||||||
"advanced_version_update_reset_config_label": "Restablecer configuración",
|
|
||||||
"advanced_version_update_system_label": "Versión del sistema",
|
|
||||||
"advanced_version_update_target_app": "Solo aplicación",
|
|
||||||
"advanced_version_update_target_both": "Tanto la aplicación como el sistema",
|
|
||||||
"advanced_version_update_target_label": "Qué actualizar",
|
|
||||||
"advanced_version_update_target_system": "Solo sistema",
|
|
||||||
"advanced_version_update_title": "Actualización a una versión específica",
|
|
||||||
"already_adopted_new_owner": "Si eres el nuevo propietario, solicita al anterior propietario que cancele el registro del dispositivo en su cuenta en el panel de control de la nube. Si crees que se trata de un error, contacta con nuestro equipo de soporte para obtener ayuda.",
|
"already_adopted_new_owner": "Si eres el nuevo propietario, solicita al anterior propietario que cancele el registro del dispositivo en su cuenta en el panel de control de la nube. Si crees que se trata de un error, contacta con nuestro equipo de soporte para obtener ayuda.",
|
||||||
"already_adopted_other_user": "Este dispositivo está actualmente registrado por otro usuario en nuestro panel de control en la nube.",
|
"already_adopted_other_user": "Este dispositivo está actualmente registrado por otro usuario en nuestro panel de control en la nube.",
|
||||||
"already_adopted_return_to_dashboard": "Regresar al panel de control",
|
"already_adopted_return_to_dashboard": "Regresar al panel de control",
|
||||||
|
|
@ -134,6 +121,50 @@
|
||||||
"atx_power_control_reset_button": "Reiniciar",
|
"atx_power_control_reset_button": "Reiniciar",
|
||||||
"atx_power_control_send_action_error": "No se pudo enviar la acción de alimentación ATX {action} : {error}",
|
"atx_power_control_send_action_error": "No se pudo enviar la acción de alimentación ATX {action} : {error}",
|
||||||
"atx_power_control_short_power_button": "Prensa corta",
|
"atx_power_control_short_power_button": "Prensa corta",
|
||||||
|
"audio_https_only": "Solo HTTPS",
|
||||||
|
"audio_input_auto_enable_disabled": "Habilitación automática de micrófono desactivada",
|
||||||
|
"audio_input_auto_enable_enabled": "Habilitación automática de micrófono activada",
|
||||||
|
"audio_input_failed_disable": "Error al desactivar la entrada de audio: {error}",
|
||||||
|
"audio_input_failed_enable": "Error al activar la entrada de audio: {error}",
|
||||||
|
"audio_microphone_description": "Entrada de micrófono al objetivo",
|
||||||
|
"audio_microphone_title": "Micrófono",
|
||||||
|
"audio_output_disabled": "Salida de audio desactivada",
|
||||||
|
"audio_output_enabled": "Salida de audio activada",
|
||||||
|
"audio_output_failed_disable": "Error al desactivar la salida de audio: {error}",
|
||||||
|
"audio_output_failed_enable": "Error al activar la salida de audio: {error}",
|
||||||
|
"audio_popover_description": "Controles de audio rápidos para altavoces y micrófono",
|
||||||
|
"audio_popover_title": "Audio",
|
||||||
|
"audio_settings_applied": "Configuración de audio aplicada",
|
||||||
|
"audio_settings_apply_button": "Aplicar configuración",
|
||||||
|
"audio_settings_auto_enable_microphone_description": "Habilitar automáticamente el micrófono del navegador al conectar (de lo contrario, debe habilitarlo manualmente en cada sesión)",
|
||||||
|
"audio_settings_auto_enable_microphone_title": "Habilitar micrófono automáticamente",
|
||||||
|
"audio_settings_bitrate_description": "Tasa de bits de codificación de audio (mayor = mejor calidad, más ancho de banda)",
|
||||||
|
"audio_settings_bitrate_title": "Bitrate Opus",
|
||||||
|
"audio_settings_buffer_description": "Tamaño del buffer ALSA (mayor = más estable, más latencia)",
|
||||||
|
"audio_settings_buffer_title": "Períodos de Buffer",
|
||||||
|
"audio_settings_complexity_description": "Complejidad del codificador (0-10, mayor = mejor calidad, más CPU)",
|
||||||
|
"audio_settings_complexity_title": "Complejidad Opus",
|
||||||
|
"audio_settings_config_updated": "Configuración de audio actualizada",
|
||||||
|
"audio_settings_description": "Configure los ajustes de entrada y salida de audio para su dispositivo JetKVM",
|
||||||
|
"audio_settings_dtx_description": "Ahorrar ancho de banda durante el silencio",
|
||||||
|
"audio_settings_dtx_title": "DTX (Transmisión Discontinua)",
|
||||||
|
"audio_settings_fec_description": "Mejorar la calidad de audio en conexiones con pérdida",
|
||||||
|
"audio_settings_fec_title": "FEC (Corrección de Errores)",
|
||||||
|
"audio_settings_hdmi_label": "HDMI",
|
||||||
|
"audio_settings_output_description": "Habilitar o deshabilitar el audio de la computadora remota",
|
||||||
|
"audio_settings_output_source_description": "Seleccione el dispositivo de captura de audio (HDMI o USB)",
|
||||||
|
"audio_settings_output_source_failed": "Error al configurar la fuente de salida de audio: {error}",
|
||||||
|
"audio_settings_output_source_success": "Fuente de salida de audio actualizada. El audio comenzará en 30-60 segundos.",
|
||||||
|
"audio_settings_output_source_title": "Fuente de salida de audio",
|
||||||
|
"audio_settings_output_title": "Salida de audio",
|
||||||
|
"audio_settings_packet_loss_description": "Porcentaje de sobrecarga FEC (mayor = mejor recuperación, más ancho de banda)",
|
||||||
|
"audio_settings_packet_loss_title": "Compensación de Pérdida de Paquetes",
|
||||||
|
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
|
||||||
|
"audio_settings_sample_rate_title": "Tasa de Muestreo",
|
||||||
|
"audio_settings_title": "Audio",
|
||||||
|
"audio_settings_usb_label": "USB",
|
||||||
|
"audio_speakers_description": "Audio del objetivo a los altavoces",
|
||||||
|
"audio_speakers_title": "Altavoces",
|
||||||
"auth_authentication_mode": "Por favor seleccione un modo de autenticación",
|
"auth_authentication_mode": "Por favor seleccione un modo de autenticación",
|
||||||
"auth_authentication_mode_error": "Se produjo un error al configurar el modo de autenticación",
|
"auth_authentication_mode_error": "Se produjo un error al configurar el modo de autenticación",
|
||||||
"auth_authentication_mode_invalid": "Modo de autenticación no válido",
|
"auth_authentication_mode_invalid": "Modo de autenticación no válido",
|
||||||
|
|
@ -193,7 +224,6 @@
|
||||||
"connection_stats_remote_ip_address": "Dirección IP remota",
|
"connection_stats_remote_ip_address": "Dirección IP remota",
|
||||||
"connection_stats_remote_ip_address_copy_error": "No se pudo copiar la dirección IP remota",
|
"connection_stats_remote_ip_address_copy_error": "No se pudo copiar la dirección IP remota",
|
||||||
"connection_stats_remote_ip_address_copy_success": "Dirección IP remota { ip } copiada al portapapeles",
|
"connection_stats_remote_ip_address_copy_success": "Dirección IP remota { ip } copiada al portapapeles",
|
||||||
"connection_stats_remote_ip_address_description": "La dirección IP del dispositivo remoto.",
|
|
||||||
"connection_stats_round_trip_time": "Tiempo de ida y vuelta",
|
"connection_stats_round_trip_time": "Tiempo de ida y vuelta",
|
||||||
"connection_stats_round_trip_time_description": "Tiempo de ida y vuelta para el par de candidatos ICE activos entre pares.",
|
"connection_stats_round_trip_time_description": "Tiempo de ida y vuelta para el par de candidatos ICE activos entre pares.",
|
||||||
"connection_stats_sidebar": "Estadísticas de conexión",
|
"connection_stats_sidebar": "Estadísticas de conexión",
|
||||||
|
|
@ -259,7 +289,6 @@
|
||||||
"general_auto_update_description": "Actualizar automáticamente el dispositivo a la última versión",
|
"general_auto_update_description": "Actualizar automáticamente el dispositivo a la última versión",
|
||||||
"general_auto_update_error": "No se pudo configurar la actualización automática: {error}",
|
"general_auto_update_error": "No se pudo configurar la actualización automática: {error}",
|
||||||
"general_auto_update_title": "Actualización automática",
|
"general_auto_update_title": "Actualización automática",
|
||||||
"general_check_for_stable_updates": "Degradar",
|
|
||||||
"general_check_for_updates": "Buscar actualizaciones",
|
"general_check_for_updates": "Buscar actualizaciones",
|
||||||
"general_page_description": "Configurar los ajustes del dispositivo y actualizar las preferencias",
|
"general_page_description": "Configurar los ajustes del dispositivo y actualizar las preferencias",
|
||||||
"general_reboot_description": "¿Desea continuar con el reinicio del sistema?",
|
"general_reboot_description": "¿Desea continuar con el reinicio del sistema?",
|
||||||
|
|
@ -280,13 +309,9 @@
|
||||||
"general_update_checking_title": "Buscando actualizaciones…",
|
"general_update_checking_title": "Buscando actualizaciones…",
|
||||||
"general_update_completed_description": "Tu dispositivo se ha actualizado correctamente a la última versión. ¡Disfruta de las nuevas funciones y mejoras!",
|
"general_update_completed_description": "Tu dispositivo se ha actualizado correctamente a la última versión. ¡Disfruta de las nuevas funciones y mejoras!",
|
||||||
"general_update_completed_title": "Actualización completada con éxito",
|
"general_update_completed_title": "Actualización completada con éxito",
|
||||||
"general_update_downgrade_available_description": "Es posible realizar una reversión a una versión anterior.",
|
|
||||||
"general_update_downgrade_available_title": "Opción de cambio a una versión inferior disponible",
|
|
||||||
"general_update_downgrade_button": "Revierte ahora",
|
|
||||||
"general_update_error_description": "Se produjo un error al actualizar tu dispositivo. Inténtalo de nuevo más tarde.",
|
"general_update_error_description": "Se produjo un error al actualizar tu dispositivo. Inténtalo de nuevo más tarde.",
|
||||||
"general_update_error_details": "Detalles del error: {errorMessage}",
|
"general_update_error_details": "Detalles del error: {errorMessage}",
|
||||||
"general_update_error_title": "Error de actualización",
|
"general_update_error_title": "Error de actualización",
|
||||||
"general_update_keep_current_button": "Mantener la versión actual",
|
|
||||||
"general_update_later_button": "Posponer",
|
"general_update_later_button": "Posponer",
|
||||||
"general_update_now_button": "Actualizar ahora",
|
"general_update_now_button": "Actualizar ahora",
|
||||||
"general_update_rebooting": "Reiniciando para completar la actualización…",
|
"general_update_rebooting": "Reiniciando para completar la actualización…",
|
||||||
|
|
@ -302,7 +327,6 @@
|
||||||
"general_update_up_to_date_title": "El sistema está actualizado",
|
"general_update_up_to_date_title": "El sistema está actualizado",
|
||||||
"general_update_updating_description": "No apagues tu dispositivo. Este proceso puede tardar unos minutos.",
|
"general_update_updating_description": "No apagues tu dispositivo. Este proceso puede tardar unos minutos.",
|
||||||
"general_update_updating_title": "Actualizar su dispositivo",
|
"general_update_updating_title": "Actualizar su dispositivo",
|
||||||
"general_update_will_disable_auto_update_description": "Estás a punto de cambiar manualmente la versión de tu dispositivo. La actualización automática se desactivará una vez completada la actualización para evitar actualizaciones accidentales.",
|
|
||||||
"getting_remote_session_description": "Obtener un intento de descripción de sesión remota {attempt}",
|
"getting_remote_session_description": "Obtener un intento de descripción de sesión remota {attempt}",
|
||||||
"hardware_backlight_settings_error": "No se pudieron configurar los ajustes de la retroiluminación: {error}",
|
"hardware_backlight_settings_error": "No se pudieron configurar los ajustes de la retroiluminación: {error}",
|
||||||
"hardware_backlight_settings_get_error": "No se pudieron obtener los ajustes de la retroiluminación: {error}",
|
"hardware_backlight_settings_get_error": "No se pudieron obtener los ajustes de la retroiluminación: {error}",
|
||||||
|
|
@ -817,6 +841,8 @@
|
||||||
"usb_device_description": "Dispositivos USB para emular en la computadora de destino",
|
"usb_device_description": "Dispositivos USB para emular en la computadora de destino",
|
||||||
"usb_device_enable_absolute_mouse_description": "Habilitar el puntero absoluto del ratón",
|
"usb_device_enable_absolute_mouse_description": "Habilitar el puntero absoluto del ratón",
|
||||||
"usb_device_enable_absolute_mouse_title": "Habilitar el puntero absoluto del ratón",
|
"usb_device_enable_absolute_mouse_title": "Habilitar el puntero absoluto del ratón",
|
||||||
|
"usb_device_enable_audio_description": "Enable bidirectional audio",
|
||||||
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Habilitar el teclado",
|
"usb_device_enable_keyboard_description": "Habilitar el teclado",
|
||||||
"usb_device_enable_keyboard_title": "Habilitar el teclado",
|
"usb_device_enable_keyboard_title": "Habilitar el teclado",
|
||||||
"usb_device_enable_mass_storage_description": "A veces puede ser necesario desactivarlo para evitar problemas con ciertos dispositivos.",
|
"usb_device_enable_mass_storage_description": "A veces puede ser necesario desactivarlo para evitar problemas con ciertos dispositivos.",
|
||||||
|
|
@ -826,6 +852,7 @@
|
||||||
"usb_device_failed_load": "No se pudieron cargar los dispositivos USB: {error}",
|
"usb_device_failed_load": "No se pudieron cargar los dispositivos USB: {error}",
|
||||||
"usb_device_failed_set": "No se pudieron configurar los dispositivos USB: {error}",
|
"usb_device_failed_set": "No se pudieron configurar los dispositivos USB: {error}",
|
||||||
"usb_device_keyboard_mouse_and_mass_storage": "Teclado, ratón y almacenamiento masivo",
|
"usb_device_keyboard_mouse_and_mass_storage": "Teclado, ratón y almacenamiento masivo",
|
||||||
|
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
|
||||||
"usb_device_keyboard_only": "Sólo teclado",
|
"usb_device_keyboard_only": "Sólo teclado",
|
||||||
"usb_device_restore_default": "Restaurar a valores predeterminados",
|
"usb_device_restore_default": "Restaurar a valores predeterminados",
|
||||||
"usb_device_title": "Dispositivo USB",
|
"usb_device_title": "Dispositivo USB",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"access_tls_self_signed": "Auto-signé",
|
"access_tls_self_signed": "Auto-signé",
|
||||||
"access_tls_updated": "Les paramètres TLS ont été mis à jour avec succès",
|
"access_tls_updated": "Les paramètres TLS ont été mis à jour avec succès",
|
||||||
"access_update_tls_settings": "Mettre à jour les paramètres TLS",
|
"access_update_tls_settings": "Mettre à jour les paramètres TLS",
|
||||||
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Statistiques de connexion",
|
"action_bar_connection_stats": "Statistiques de connexion",
|
||||||
"action_bar_extension": "Extension",
|
"action_bar_extension": "Extension",
|
||||||
"action_bar_fullscreen": "Plein écran",
|
"action_bar_fullscreen": "Plein écran",
|
||||||
|
|
@ -74,7 +75,6 @@
|
||||||
"advanced_error_update_ssh_key": "Échec de la mise à jour de la clé SSH : {error}",
|
"advanced_error_update_ssh_key": "Échec de la mise à jour de la clé SSH : {error}",
|
||||||
"advanced_error_usb_emulation_disable": "Échec de la désactivation de l'émulation USB : {error}",
|
"advanced_error_usb_emulation_disable": "Échec de la désactivation de l'émulation USB : {error}",
|
||||||
"advanced_error_usb_emulation_enable": "Échec de l'activation de l'émulation USB : {error}",
|
"advanced_error_usb_emulation_enable": "Échec de l'activation de l'émulation USB : {error}",
|
||||||
"advanced_error_version_update": "Échec de la mise à jour de version : {error}",
|
|
||||||
"advanced_loopback_only_description": "Restreindre l'accès à l'interface Web à l'hôte local uniquement (127.0.0.1)",
|
"advanced_loopback_only_description": "Restreindre l'accès à l'interface Web à l'hôte local uniquement (127.0.0.1)",
|
||||||
"advanced_loopback_only_title": "Mode de bouclage uniquement",
|
"advanced_loopback_only_title": "Mode de bouclage uniquement",
|
||||||
"advanced_loopback_warning_before": "Avant d'activer cette fonctionnalité, assurez-vous d'avoir :",
|
"advanced_loopback_warning_before": "Avant d'activer cette fonctionnalité, assurez-vous d'avoir :",
|
||||||
|
|
@ -101,19 +101,6 @@
|
||||||
"advanced_update_ssh_key_button": "Mettre à jour la clé SSH",
|
"advanced_update_ssh_key_button": "Mettre à jour la clé SSH",
|
||||||
"advanced_usb_emulation_description": "Contrôler l'état de l'émulation USB",
|
"advanced_usb_emulation_description": "Contrôler l'état de l'émulation USB",
|
||||||
"advanced_usb_emulation_title": "Émulation USB",
|
"advanced_usb_emulation_title": "Émulation USB",
|
||||||
"advanced_version_update_app_label": "Version de l'application",
|
|
||||||
"advanced_version_update_button": "Mise à jour vers la version",
|
|
||||||
"advanced_version_update_description": "Installer une version spécifique à partir des versions GitHub",
|
|
||||||
"advanced_version_update_github_link": "page des versions de JetKVM",
|
|
||||||
"advanced_version_update_helper": "Trouvez les versions disponibles sur le",
|
|
||||||
"advanced_version_update_reset_config_description": "Réinitialiser la configuration après la mise à jour",
|
|
||||||
"advanced_version_update_reset_config_label": "Réinitialiser la configuration",
|
|
||||||
"advanced_version_update_system_label": "Version du système",
|
|
||||||
"advanced_version_update_target_app": "Application uniquement",
|
|
||||||
"advanced_version_update_target_both": "L'application et le système",
|
|
||||||
"advanced_version_update_target_label": "Que mettre à jour",
|
|
||||||
"advanced_version_update_target_system": "Système uniquement",
|
|
||||||
"advanced_version_update_title": "Mise à jour vers une version spécifique",
|
|
||||||
"already_adopted_new_owner": "Si vous êtes le nouveau propriétaire, veuillez demander à l'ancien propriétaire de désenregistrer l'appareil de son compte dans le tableau de bord cloud. Si vous pensez qu'il s'agit d'une erreur, contactez notre équipe d'assistance pour obtenir de l'aide.",
|
"already_adopted_new_owner": "Si vous êtes le nouveau propriétaire, veuillez demander à l'ancien propriétaire de désenregistrer l'appareil de son compte dans le tableau de bord cloud. Si vous pensez qu'il s'agit d'une erreur, contactez notre équipe d'assistance pour obtenir de l'aide.",
|
||||||
"already_adopted_other_user": "Cet appareil est actuellement enregistré auprès d'un autre utilisateur dans notre tableau de bord cloud.",
|
"already_adopted_other_user": "Cet appareil est actuellement enregistré auprès d'un autre utilisateur dans notre tableau de bord cloud.",
|
||||||
"already_adopted_return_to_dashboard": "Retour au tableau de bord",
|
"already_adopted_return_to_dashboard": "Retour au tableau de bord",
|
||||||
|
|
@ -134,6 +121,50 @@
|
||||||
"atx_power_control_reset_button": "Réinitialiser",
|
"atx_power_control_reset_button": "Réinitialiser",
|
||||||
"atx_power_control_send_action_error": "Échec de l'envoi de l'action d'alimentation ATX {action} : {error}",
|
"atx_power_control_send_action_error": "Échec de l'envoi de l'action d'alimentation ATX {action} : {error}",
|
||||||
"atx_power_control_short_power_button": "Appui court",
|
"atx_power_control_short_power_button": "Appui court",
|
||||||
|
"audio_https_only": "HTTPS uniquement",
|
||||||
|
"audio_input_auto_enable_disabled": "Activation automatique du microphone désactivée",
|
||||||
|
"audio_input_auto_enable_enabled": "Activation automatique du microphone activée",
|
||||||
|
"audio_input_failed_disable": "Échec de la désactivation de l'entrée audio : {error}",
|
||||||
|
"audio_input_failed_enable": "Échec de l'activation de l'entrée audio : {error}",
|
||||||
|
"audio_microphone_description": "Entrée microphone vers la cible",
|
||||||
|
"audio_microphone_title": "Microphone",
|
||||||
|
"audio_output_disabled": "Sortie audio désactivée",
|
||||||
|
"audio_output_enabled": "Sortie audio activée",
|
||||||
|
"audio_output_failed_disable": "Échec de la désactivation de la sortie audio : {error}",
|
||||||
|
"audio_output_failed_enable": "Échec de l'activation de la sortie audio : {error}",
|
||||||
|
"audio_popover_description": "Contrôles audio rapides pour haut-parleurs et microphone",
|
||||||
|
"audio_popover_title": "Audio",
|
||||||
|
"audio_settings_applied": "Paramètres audio appliqués",
|
||||||
|
"audio_settings_apply_button": "Appliquer les paramètres",
|
||||||
|
"audio_settings_auto_enable_microphone_description": "Activer automatiquement le microphone du navigateur lors de la connexion (sinon vous devez l'activer manuellement à chaque session)",
|
||||||
|
"audio_settings_auto_enable_microphone_title": "Activer automatiquement le microphone",
|
||||||
|
"audio_settings_bitrate_description": "Débit d'encodage audio (plus élevé = meilleure qualité, plus de bande passante)",
|
||||||
|
"audio_settings_bitrate_title": "Débit Opus",
|
||||||
|
"audio_settings_buffer_description": "Taille du tampon ALSA (plus élevé = plus stable, plus de latence)",
|
||||||
|
"audio_settings_buffer_title": "Périodes de Tampon",
|
||||||
|
"audio_settings_complexity_description": "Complexité de l'encodeur (0-10, plus élevé = meilleure qualité, plus de CPU)",
|
||||||
|
"audio_settings_complexity_title": "Complexité Opus",
|
||||||
|
"audio_settings_config_updated": "Configuration audio mise à jour",
|
||||||
|
"audio_settings_description": "Configurez les paramètres d'entrée et de sortie audio pour votre appareil JetKVM",
|
||||||
|
"audio_settings_dtx_description": "Économiser la bande passante pendant le silence",
|
||||||
|
"audio_settings_dtx_title": "DTX (Transmission Discontinue)",
|
||||||
|
"audio_settings_fec_description": "Améliorer la qualité audio sur les connexions avec perte",
|
||||||
|
"audio_settings_fec_title": "FEC (Correction d'Erreur)",
|
||||||
|
"audio_settings_hdmi_label": "HDMI",
|
||||||
|
"audio_settings_output_description": "Activer ou désactiver l'audio de l'ordinateur distant",
|
||||||
|
"audio_settings_output_source_description": "Sélectionnez le périphérique de capture audio (HDMI ou USB)",
|
||||||
|
"audio_settings_output_source_failed": "Échec de la configuration de la source de sortie audio : {error}",
|
||||||
|
"audio_settings_output_source_success": "Source de sortie audio mise à jour. L'audio démarrera dans 30 à 60 secondes.",
|
||||||
|
"audio_settings_output_source_title": "Source de sortie audio",
|
||||||
|
"audio_settings_output_title": "Sortie audio",
|
||||||
|
"audio_settings_packet_loss_description": "Pourcentage de surcharge FEC (plus élevé = meilleure récupération, plus de bande passante)",
|
||||||
|
"audio_settings_packet_loss_title": "Compensation de Perte de Paquets",
|
||||||
|
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
|
||||||
|
"audio_settings_sample_rate_title": "Fréquence d'Échantillonnage",
|
||||||
|
"audio_settings_title": "Audio",
|
||||||
|
"audio_settings_usb_label": "USB",
|
||||||
|
"audio_speakers_description": "Audio de la cible vers les haut-parleurs",
|
||||||
|
"audio_speakers_title": "Haut-parleurs",
|
||||||
"auth_authentication_mode": "Veuillez sélectionner un mode d'authentification",
|
"auth_authentication_mode": "Veuillez sélectionner un mode d'authentification",
|
||||||
"auth_authentication_mode_error": "Une erreur s'est produite lors de la définition du mode d'authentification",
|
"auth_authentication_mode_error": "Une erreur s'est produite lors de la définition du mode d'authentification",
|
||||||
"auth_authentication_mode_invalid": "Mode d'authentification non valide",
|
"auth_authentication_mode_invalid": "Mode d'authentification non valide",
|
||||||
|
|
@ -193,7 +224,6 @@
|
||||||
"connection_stats_remote_ip_address": "Adresse IP distante",
|
"connection_stats_remote_ip_address": "Adresse IP distante",
|
||||||
"connection_stats_remote_ip_address_copy_error": "Échec de la copie de l'adresse IP distante",
|
"connection_stats_remote_ip_address_copy_error": "Échec de la copie de l'adresse IP distante",
|
||||||
"connection_stats_remote_ip_address_copy_success": "Adresse IP distante { ip } copiée dans le presse-papiers",
|
"connection_stats_remote_ip_address_copy_success": "Adresse IP distante { ip } copiée dans le presse-papiers",
|
||||||
"connection_stats_remote_ip_address_description": "L'adresse IP du périphérique distant.",
|
|
||||||
"connection_stats_round_trip_time": "Temps de trajet aller-retour",
|
"connection_stats_round_trip_time": "Temps de trajet aller-retour",
|
||||||
"connection_stats_round_trip_time_description": "Temps de trajet aller-retour pour la paire de candidats ICE actifs entre pairs.",
|
"connection_stats_round_trip_time_description": "Temps de trajet aller-retour pour la paire de candidats ICE actifs entre pairs.",
|
||||||
"connection_stats_sidebar": "Statistiques de connexion",
|
"connection_stats_sidebar": "Statistiques de connexion",
|
||||||
|
|
@ -259,7 +289,6 @@
|
||||||
"general_auto_update_description": "Mettre à jour automatiquement l'appareil vers la dernière version",
|
"general_auto_update_description": "Mettre à jour automatiquement l'appareil vers la dernière version",
|
||||||
"general_auto_update_error": "Échec de la définition de la mise à jour automatique : {error}",
|
"general_auto_update_error": "Échec de la définition de la mise à jour automatique : {error}",
|
||||||
"general_auto_update_title": "Mise à jour automatique",
|
"general_auto_update_title": "Mise à jour automatique",
|
||||||
"general_check_for_stable_updates": "Rétrograder",
|
|
||||||
"general_check_for_updates": "Vérifier les mises à jour",
|
"general_check_for_updates": "Vérifier les mises à jour",
|
||||||
"general_page_description": "Configurer les paramètres de l'appareil et mettre à jour les préférences",
|
"general_page_description": "Configurer les paramètres de l'appareil et mettre à jour les préférences",
|
||||||
"general_reboot_description": "Voulez-vous procéder au redémarrage du système ?",
|
"general_reboot_description": "Voulez-vous procéder au redémarrage du système ?",
|
||||||
|
|
@ -280,13 +309,9 @@
|
||||||
"general_update_checking_title": "Vérification des mises à jour…",
|
"general_update_checking_title": "Vérification des mises à jour…",
|
||||||
"general_update_completed_description": "Votre appareil a été mis à jour avec succès vers la dernière version. Profitez des nouvelles fonctionnalités et améliorations !",
|
"general_update_completed_description": "Votre appareil a été mis à jour avec succès vers la dernière version. Profitez des nouvelles fonctionnalités et améliorations !",
|
||||||
"general_update_completed_title": "Mise à jour terminée avec succès",
|
"general_update_completed_title": "Mise à jour terminée avec succès",
|
||||||
"general_update_downgrade_available_description": "Il est possible de revenir à une version antérieure.",
|
|
||||||
"general_update_downgrade_available_title": "Rétrogradation possible",
|
|
||||||
"general_update_downgrade_button": "Rétrograder maintenant",
|
|
||||||
"general_update_error_description": "Une erreur s'est produite lors de la mise à jour de votre appareil. Veuillez réessayer ultérieurement.",
|
"general_update_error_description": "Une erreur s'est produite lors de la mise à jour de votre appareil. Veuillez réessayer ultérieurement.",
|
||||||
"general_update_error_details": "Détails de l'erreur : {errorMessage}",
|
"general_update_error_details": "Détails de l'erreur : {errorMessage}",
|
||||||
"general_update_error_title": "Erreur de mise à jour",
|
"general_update_error_title": "Erreur de mise à jour",
|
||||||
"general_update_keep_current_button": "Conserver la version actuelle",
|
|
||||||
"general_update_later_button": "Faire plus tard",
|
"general_update_later_button": "Faire plus tard",
|
||||||
"general_update_now_button": "Mettre à jour maintenant",
|
"general_update_now_button": "Mettre à jour maintenant",
|
||||||
"general_update_rebooting": "Redémarrage pour terminer la mise à jour…",
|
"general_update_rebooting": "Redémarrage pour terminer la mise à jour…",
|
||||||
|
|
@ -302,7 +327,6 @@
|
||||||
"general_update_up_to_date_title": "Le système est à jour",
|
"general_update_up_to_date_title": "Le système est à jour",
|
||||||
"general_update_updating_description": "Veuillez ne pas éteindre votre appareil. Ce processus peut prendre quelques minutes.",
|
"general_update_updating_description": "Veuillez ne pas éteindre votre appareil. Ce processus peut prendre quelques minutes.",
|
||||||
"general_update_updating_title": "Mise à jour de votre appareil",
|
"general_update_updating_title": "Mise à jour de votre appareil",
|
||||||
"general_update_will_disable_auto_update_description": "Vous allez modifier manuellement la version de votre appareil. La mise à jour automatique sera désactivée une fois la mise à jour terminée afin d'éviter toute mise à jour accidentelle.",
|
|
||||||
"getting_remote_session_description": "Obtention d'{attempt} description de session à distance",
|
"getting_remote_session_description": "Obtention d'{attempt} description de session à distance",
|
||||||
"hardware_backlight_settings_error": "Échec de la définition des paramètres de rétroéclairage : {error}",
|
"hardware_backlight_settings_error": "Échec de la définition des paramètres de rétroéclairage : {error}",
|
||||||
"hardware_backlight_settings_get_error": "Échec de l'obtention des paramètres de rétroéclairage : {error}",
|
"hardware_backlight_settings_get_error": "Échec de l'obtention des paramètres de rétroéclairage : {error}",
|
||||||
|
|
@ -817,6 +841,8 @@
|
||||||
"usb_device_description": "Périphériques USB à émuler sur l'ordinateur cible",
|
"usb_device_description": "Périphériques USB à émuler sur l'ordinateur cible",
|
||||||
"usb_device_enable_absolute_mouse_description": "Activer la souris absolue (pointeur)",
|
"usb_device_enable_absolute_mouse_description": "Activer la souris absolue (pointeur)",
|
||||||
"usb_device_enable_absolute_mouse_title": "Activer la souris absolue (pointeur)",
|
"usb_device_enable_absolute_mouse_title": "Activer la souris absolue (pointeur)",
|
||||||
|
"usb_device_enable_audio_description": "Enable bidirectional audio",
|
||||||
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Activer le clavier",
|
"usb_device_enable_keyboard_description": "Activer le clavier",
|
||||||
"usb_device_enable_keyboard_title": "Activer le clavier",
|
"usb_device_enable_keyboard_title": "Activer le clavier",
|
||||||
"usb_device_enable_mass_storage_description": "Parfois, il peut être nécessaire de le désactiver pour éviter des problèmes avec certains appareils",
|
"usb_device_enable_mass_storage_description": "Parfois, il peut être nécessaire de le désactiver pour éviter des problèmes avec certains appareils",
|
||||||
|
|
@ -826,6 +852,7 @@
|
||||||
"usb_device_failed_load": "Échec du chargement des périphériques USB : {error}",
|
"usb_device_failed_load": "Échec du chargement des périphériques USB : {error}",
|
||||||
"usb_device_failed_set": "Échec de la configuration des périphériques USB : {error}",
|
"usb_device_failed_set": "Échec de la configuration des périphériques USB : {error}",
|
||||||
"usb_device_keyboard_mouse_and_mass_storage": "Clavier, souris et stockage de masse",
|
"usb_device_keyboard_mouse_and_mass_storage": "Clavier, souris et stockage de masse",
|
||||||
|
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
|
||||||
"usb_device_keyboard_only": "Clavier uniquement",
|
"usb_device_keyboard_only": "Clavier uniquement",
|
||||||
"usb_device_restore_default": "Restaurer les paramètres par défaut",
|
"usb_device_restore_default": "Restaurer les paramètres par défaut",
|
||||||
"usb_device_title": "périphérique USB",
|
"usb_device_title": "périphérique USB",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"access_tls_self_signed": "Autofirmato",
|
"access_tls_self_signed": "Autofirmato",
|
||||||
"access_tls_updated": "Impostazioni TLS aggiornate correttamente",
|
"access_tls_updated": "Impostazioni TLS aggiornate correttamente",
|
||||||
"access_update_tls_settings": "Aggiorna le impostazioni TLS",
|
"access_update_tls_settings": "Aggiorna le impostazioni TLS",
|
||||||
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Statistiche di connessione",
|
"action_bar_connection_stats": "Statistiche di connessione",
|
||||||
"action_bar_extension": "Estensione",
|
"action_bar_extension": "Estensione",
|
||||||
"action_bar_fullscreen": "A schermo intero",
|
"action_bar_fullscreen": "A schermo intero",
|
||||||
|
|
@ -74,7 +75,6 @@
|
||||||
"advanced_error_update_ssh_key": "Impossibile aggiornare la chiave SSH: {error}",
|
"advanced_error_update_ssh_key": "Impossibile aggiornare la chiave SSH: {error}",
|
||||||
"advanced_error_usb_emulation_disable": "Impossibile disabilitare l'emulazione USB: {error}",
|
"advanced_error_usb_emulation_disable": "Impossibile disabilitare l'emulazione USB: {error}",
|
||||||
"advanced_error_usb_emulation_enable": "Impossibile abilitare l'emulazione USB: {error}",
|
"advanced_error_usb_emulation_enable": "Impossibile abilitare l'emulazione USB: {error}",
|
||||||
"advanced_error_version_update": "Impossibile avviare l'aggiornamento della versione: {error}",
|
|
||||||
"advanced_loopback_only_description": "Limita l'accesso all'interfaccia web solo a localhost (127.0.0.1)",
|
"advanced_loopback_only_description": "Limita l'accesso all'interfaccia web solo a localhost (127.0.0.1)",
|
||||||
"advanced_loopback_only_title": "Modalità solo loopback",
|
"advanced_loopback_only_title": "Modalità solo loopback",
|
||||||
"advanced_loopback_warning_before": "Prima di abilitare questa funzione, assicurati di avere:",
|
"advanced_loopback_warning_before": "Prima di abilitare questa funzione, assicurati di avere:",
|
||||||
|
|
@ -101,19 +101,6 @@
|
||||||
"advanced_update_ssh_key_button": "Aggiorna la chiave SSH",
|
"advanced_update_ssh_key_button": "Aggiorna la chiave SSH",
|
||||||
"advanced_usb_emulation_description": "Controlla lo stato di emulazione USB",
|
"advanced_usb_emulation_description": "Controlla lo stato di emulazione USB",
|
||||||
"advanced_usb_emulation_title": "Emulazione USB",
|
"advanced_usb_emulation_title": "Emulazione USB",
|
||||||
"advanced_version_update_app_label": "Versione dell'app",
|
|
||||||
"advanced_version_update_button": "Aggiorna alla versione",
|
|
||||||
"advanced_version_update_description": "Installa una versione specifica dalle versioni di GitHub",
|
|
||||||
"advanced_version_update_github_link": "Pagina delle versioni di JetKVM",
|
|
||||||
"advanced_version_update_helper": "Trova le versioni disponibili su",
|
|
||||||
"advanced_version_update_reset_config_description": "Ripristina la configurazione dopo l'aggiornamento",
|
|
||||||
"advanced_version_update_reset_config_label": "Reimposta configurazione",
|
|
||||||
"advanced_version_update_system_label": "Versione del sistema",
|
|
||||||
"advanced_version_update_target_app": "Solo app",
|
|
||||||
"advanced_version_update_target_both": "Sia l'app che il sistema",
|
|
||||||
"advanced_version_update_target_label": "Cosa aggiornare",
|
|
||||||
"advanced_version_update_target_system": "Solo sistema",
|
|
||||||
"advanced_version_update_title": "Aggiorna alla versione specifica",
|
|
||||||
"already_adopted_new_owner": "Se sei il nuovo proprietario, chiedi al precedente proprietario di annullare la registrazione del dispositivo dal suo account nella dashboard cloud. Se ritieni che si tratti di un errore, contatta il nostro team di supporto per ricevere assistenza.",
|
"already_adopted_new_owner": "Se sei il nuovo proprietario, chiedi al precedente proprietario di annullare la registrazione del dispositivo dal suo account nella dashboard cloud. Se ritieni che si tratti di un errore, contatta il nostro team di supporto per ricevere assistenza.",
|
||||||
"already_adopted_other_user": "Questo dispositivo è attualmente registrato a un altro utente nella nostra dashboard cloud.",
|
"already_adopted_other_user": "Questo dispositivo è attualmente registrato a un altro utente nella nostra dashboard cloud.",
|
||||||
"already_adopted_return_to_dashboard": "Torna alla dashboard",
|
"already_adopted_return_to_dashboard": "Torna alla dashboard",
|
||||||
|
|
@ -134,6 +121,50 @@
|
||||||
"atx_power_control_reset_button": "Reset",
|
"atx_power_control_reset_button": "Reset",
|
||||||
"atx_power_control_send_action_error": "Impossibile inviare l'azione di alimentazione ATX {action} : {error}",
|
"atx_power_control_send_action_error": "Impossibile inviare l'azione di alimentazione ATX {action} : {error}",
|
||||||
"atx_power_control_short_power_button": "Pressione breve",
|
"atx_power_control_short_power_button": "Pressione breve",
|
||||||
|
"audio_https_only": "Solo HTTPS",
|
||||||
|
"audio_input_auto_enable_disabled": "Abilitazione automatica microfono disabilitata",
|
||||||
|
"audio_input_auto_enable_enabled": "Abilitazione automatica microfono abilitata",
|
||||||
|
"audio_input_failed_disable": "Impossibile disabilitare l'ingresso audio: {error}",
|
||||||
|
"audio_input_failed_enable": "Impossibile abilitare l'ingresso audio: {error}",
|
||||||
|
"audio_microphone_description": "Ingresso microfono al target",
|
||||||
|
"audio_microphone_title": "Microfono",
|
||||||
|
"audio_output_disabled": "Uscita audio disabilitata",
|
||||||
|
"audio_output_enabled": "Uscita audio abilitata",
|
||||||
|
"audio_output_failed_disable": "Impossibile disabilitare l'uscita audio: {error}",
|
||||||
|
"audio_output_failed_enable": "Impossibile abilitare l'uscita audio: {error}",
|
||||||
|
"audio_popover_description": "Controlli audio rapidi per altoparlanti e microfono",
|
||||||
|
"audio_popover_title": "Audio",
|
||||||
|
"audio_settings_applied": "Impostazioni audio applicate",
|
||||||
|
"audio_settings_apply_button": "Applica impostazioni",
|
||||||
|
"audio_settings_auto_enable_microphone_description": "Abilita automaticamente il microfono del browser durante la connessione (altrimenti devi abilitarlo manualmente ad ogni sessione)",
|
||||||
|
"audio_settings_auto_enable_microphone_title": "Abilita automaticamente il microfono",
|
||||||
|
"audio_settings_bitrate_description": "Bitrate di codifica audio (più alto = migliore qualità, più banda)",
|
||||||
|
"audio_settings_bitrate_title": "Bitrate Opus",
|
||||||
|
"audio_settings_buffer_description": "Dimensione buffer ALSA (più alto = più stabile, più latenza)",
|
||||||
|
"audio_settings_buffer_title": "Periodi Buffer",
|
||||||
|
"audio_settings_complexity_description": "Complessità dell'encoder (0-10, più alto = migliore qualità, più CPU)",
|
||||||
|
"audio_settings_complexity_title": "Complessità Opus",
|
||||||
|
"audio_settings_config_updated": "Configurazione audio aggiornata",
|
||||||
|
"audio_settings_description": "Configura le impostazioni di ingresso e uscita audio per il tuo dispositivo JetKVM",
|
||||||
|
"audio_settings_dtx_description": "Risparmia banda durante il silenzio",
|
||||||
|
"audio_settings_dtx_title": "DTX (Trasmissione Discontinua)",
|
||||||
|
"audio_settings_fec_description": "Migliora la qualità audio su connessioni con perdita",
|
||||||
|
"audio_settings_fec_title": "FEC (Correzione Errori)",
|
||||||
|
"audio_settings_hdmi_label": "HDMI",
|
||||||
|
"audio_settings_output_description": "Abilita o disabilita l'audio dal computer remoto",
|
||||||
|
"audio_settings_output_source_description": "Seleziona il dispositivo di acquisizione audio (HDMI o USB)",
|
||||||
|
"audio_settings_output_source_failed": "Impossibile impostare la sorgente di uscita audio: {error}",
|
||||||
|
"audio_settings_output_source_success": "Sorgente di uscita audio aggiornata. L'audio inizierà tra 30-60 secondi.",
|
||||||
|
"audio_settings_output_source_title": "Sorgente di uscita audio",
|
||||||
|
"audio_settings_output_title": "Uscita audio",
|
||||||
|
"audio_settings_packet_loss_description": "Percentuale overhead FEC (più alto = migliore recupero, più banda)",
|
||||||
|
"audio_settings_packet_loss_title": "Compensazione Perdita Pacchetti",
|
||||||
|
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
|
||||||
|
"audio_settings_sample_rate_title": "Frequenza di Campionamento",
|
||||||
|
"audio_settings_title": "Audio",
|
||||||
|
"audio_settings_usb_label": "USB",
|
||||||
|
"audio_speakers_description": "Audio dal target agli altoparlanti",
|
||||||
|
"audio_speakers_title": "Altoparlanti",
|
||||||
"auth_authentication_mode": "Seleziona una modalità di autenticazione",
|
"auth_authentication_mode": "Seleziona una modalità di autenticazione",
|
||||||
"auth_authentication_mode_error": "Si è verificato un errore durante l'impostazione della modalità di autenticazione",
|
"auth_authentication_mode_error": "Si è verificato un errore durante l'impostazione della modalità di autenticazione",
|
||||||
"auth_authentication_mode_invalid": "Modalità di autenticazione non valida",
|
"auth_authentication_mode_invalid": "Modalità di autenticazione non valida",
|
||||||
|
|
@ -193,7 +224,6 @@
|
||||||
"connection_stats_remote_ip_address": "Indirizzo IP remoto",
|
"connection_stats_remote_ip_address": "Indirizzo IP remoto",
|
||||||
"connection_stats_remote_ip_address_copy_error": "Impossibile copiare l'indirizzo IP remoto",
|
"connection_stats_remote_ip_address_copy_error": "Impossibile copiare l'indirizzo IP remoto",
|
||||||
"connection_stats_remote_ip_address_copy_success": "Indirizzo IP remoto { ip } copiato negli appunti",
|
"connection_stats_remote_ip_address_copy_success": "Indirizzo IP remoto { ip } copiato negli appunti",
|
||||||
"connection_stats_remote_ip_address_description": "L'indirizzo IP del dispositivo remoto.",
|
|
||||||
"connection_stats_round_trip_time": "Tempo di andata e ritorno",
|
"connection_stats_round_trip_time": "Tempo di andata e ritorno",
|
||||||
"connection_stats_round_trip_time_description": "Tempo di andata e ritorno per la coppia di candidati ICE attivi tra pari.",
|
"connection_stats_round_trip_time_description": "Tempo di andata e ritorno per la coppia di candidati ICE attivi tra pari.",
|
||||||
"connection_stats_sidebar": "Statistiche di connessione",
|
"connection_stats_sidebar": "Statistiche di connessione",
|
||||||
|
|
@ -259,7 +289,6 @@
|
||||||
"general_auto_update_description": "Aggiorna automaticamente il dispositivo all'ultima versione",
|
"general_auto_update_description": "Aggiorna automaticamente il dispositivo all'ultima versione",
|
||||||
"general_auto_update_error": "Impossibile impostare l'aggiornamento automatico: {error}",
|
"general_auto_update_error": "Impossibile impostare l'aggiornamento automatico: {error}",
|
||||||
"general_auto_update_title": "Aggiornamento automatico",
|
"general_auto_update_title": "Aggiornamento automatico",
|
||||||
"general_check_for_stable_updates": "Declassare",
|
|
||||||
"general_check_for_updates": "Verifica aggiornamenti",
|
"general_check_for_updates": "Verifica aggiornamenti",
|
||||||
"general_page_description": "Configurare le impostazioni del dispositivo e aggiornare le preferenze",
|
"general_page_description": "Configurare le impostazioni del dispositivo e aggiornare le preferenze",
|
||||||
"general_reboot_description": "Vuoi procedere con il riavvio del sistema?",
|
"general_reboot_description": "Vuoi procedere con il riavvio del sistema?",
|
||||||
|
|
@ -280,13 +309,9 @@
|
||||||
"general_update_checking_title": "Controllo degli aggiornamenti…",
|
"general_update_checking_title": "Controllo degli aggiornamenti…",
|
||||||
"general_update_completed_description": "Il tuo dispositivo è stato aggiornato con successo all'ultima versione. Goditi le nuove funzionalità e i miglioramenti!",
|
"general_update_completed_description": "Il tuo dispositivo è stato aggiornato con successo all'ultima versione. Goditi le nuove funzionalità e i miglioramenti!",
|
||||||
"general_update_completed_title": "Aggiornamento completato con successo",
|
"general_update_completed_title": "Aggiornamento completato con successo",
|
||||||
"general_update_downgrade_available_description": "È possibile effettuare il downgrade per tornare a una versione precedente.",
|
|
||||||
"general_update_downgrade_available_title": "Downgrade disponibile",
|
|
||||||
"general_update_downgrade_button": "Effettua il downgrade ora",
|
|
||||||
"general_update_error_description": "Si è verificato un errore durante l'aggiornamento del dispositivo. Riprova più tardi.",
|
"general_update_error_description": "Si è verificato un errore durante l'aggiornamento del dispositivo. Riprova più tardi.",
|
||||||
"general_update_error_details": "Dettagli errore: {errorMessage}",
|
"general_update_error_details": "Dettagli errore: {errorMessage}",
|
||||||
"general_update_error_title": "Errore di aggiornamento",
|
"general_update_error_title": "Errore di aggiornamento",
|
||||||
"general_update_keep_current_button": "Mantieni la versione corrente",
|
|
||||||
"general_update_later_button": "Fallo più tardi",
|
"general_update_later_button": "Fallo più tardi",
|
||||||
"general_update_now_button": "Aggiorna ora",
|
"general_update_now_button": "Aggiorna ora",
|
||||||
"general_update_rebooting": "Riavvio per completare l'aggiornamento…",
|
"general_update_rebooting": "Riavvio per completare l'aggiornamento…",
|
||||||
|
|
@ -302,7 +327,6 @@
|
||||||
"general_update_up_to_date_title": "Il sistema è aggiornato",
|
"general_update_up_to_date_title": "Il sistema è aggiornato",
|
||||||
"general_update_updating_description": "Non spegnere il dispositivo. Questo processo potrebbe richiedere alcuni minuti.",
|
"general_update_updating_description": "Non spegnere il dispositivo. Questo processo potrebbe richiedere alcuni minuti.",
|
||||||
"general_update_updating_title": "Aggiornamento del dispositivo",
|
"general_update_updating_title": "Aggiornamento del dispositivo",
|
||||||
"general_update_will_disable_auto_update_description": "Stai per modificare manualmente la versione del tuo dispositivo. L'aggiornamento automatico verrà disattivato al termine dell'aggiornamento per evitare aggiornamenti accidentali.",
|
|
||||||
"getting_remote_session_description": "Tentativo di ottenimento della descrizione della sessione remota {attempt}",
|
"getting_remote_session_description": "Tentativo di ottenimento della descrizione della sessione remota {attempt}",
|
||||||
"hardware_backlight_settings_error": "Impossibile impostare le impostazioni della retroilluminazione: {error}",
|
"hardware_backlight_settings_error": "Impossibile impostare le impostazioni della retroilluminazione: {error}",
|
||||||
"hardware_backlight_settings_get_error": "Impossibile ottenere le impostazioni della retroilluminazione: {error}",
|
"hardware_backlight_settings_get_error": "Impossibile ottenere le impostazioni della retroilluminazione: {error}",
|
||||||
|
|
@ -817,6 +841,8 @@
|
||||||
"usb_device_description": "Dispositivi USB da emulare sul computer di destinazione",
|
"usb_device_description": "Dispositivi USB da emulare sul computer di destinazione",
|
||||||
"usb_device_enable_absolute_mouse_description": "Abilita mouse assoluto (puntatore)",
|
"usb_device_enable_absolute_mouse_description": "Abilita mouse assoluto (puntatore)",
|
||||||
"usb_device_enable_absolute_mouse_title": "Abilita mouse assoluto (puntatore)",
|
"usb_device_enable_absolute_mouse_title": "Abilita mouse assoluto (puntatore)",
|
||||||
|
"usb_device_enable_audio_description": "Enable bidirectional audio",
|
||||||
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Abilita tastiera",
|
"usb_device_enable_keyboard_description": "Abilita tastiera",
|
||||||
"usb_device_enable_keyboard_title": "Abilita tastiera",
|
"usb_device_enable_keyboard_title": "Abilita tastiera",
|
||||||
"usb_device_enable_mass_storage_description": "A volte potrebbe essere necessario disattivarlo per evitare problemi con determinati dispositivi",
|
"usb_device_enable_mass_storage_description": "A volte potrebbe essere necessario disattivarlo per evitare problemi con determinati dispositivi",
|
||||||
|
|
@ -826,6 +852,7 @@
|
||||||
"usb_device_failed_load": "Impossibile caricare i dispositivi USB: {error}",
|
"usb_device_failed_load": "Impossibile caricare i dispositivi USB: {error}",
|
||||||
"usb_device_failed_set": "Impossibile impostare i dispositivi USB: {error}",
|
"usb_device_failed_set": "Impossibile impostare i dispositivi USB: {error}",
|
||||||
"usb_device_keyboard_mouse_and_mass_storage": "Tastiera, mouse e memoria di massa",
|
"usb_device_keyboard_mouse_and_mass_storage": "Tastiera, mouse e memoria di massa",
|
||||||
|
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
|
||||||
"usb_device_keyboard_only": "Solo tastiera",
|
"usb_device_keyboard_only": "Solo tastiera",
|
||||||
"usb_device_restore_default": "Ripristina impostazioni predefinite",
|
"usb_device_restore_default": "Ripristina impostazioni predefinite",
|
||||||
"usb_device_title": "Dispositivo USB",
|
"usb_device_title": "Dispositivo USB",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"access_tls_self_signed": "Selvsignert",
|
"access_tls_self_signed": "Selvsignert",
|
||||||
"access_tls_updated": "TLS-innstillingene er oppdatert",
|
"access_tls_updated": "TLS-innstillingene er oppdatert",
|
||||||
"access_update_tls_settings": "Oppdater TLS-innstillinger",
|
"access_update_tls_settings": "Oppdater TLS-innstillinger",
|
||||||
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Tilkoblingsstatistikk",
|
"action_bar_connection_stats": "Tilkoblingsstatistikk",
|
||||||
"action_bar_extension": "Forlengelse",
|
"action_bar_extension": "Forlengelse",
|
||||||
"action_bar_fullscreen": "Fullskjerm",
|
"action_bar_fullscreen": "Fullskjerm",
|
||||||
|
|
@ -74,7 +75,6 @@
|
||||||
"advanced_error_update_ssh_key": "Kunne ikke oppdatere SSH-nøkkelen: {error}",
|
"advanced_error_update_ssh_key": "Kunne ikke oppdatere SSH-nøkkelen: {error}",
|
||||||
"advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}",
|
"advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}",
|
||||||
"advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}",
|
"advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}",
|
||||||
"advanced_error_version_update": "Kunne ikke starte versjonsoppdatering: {error}",
|
|
||||||
"advanced_loopback_only_description": "Begrens tilgang til webgrensesnittet kun til lokal vert (127.0.0.1)",
|
"advanced_loopback_only_description": "Begrens tilgang til webgrensesnittet kun til lokal vert (127.0.0.1)",
|
||||||
"advanced_loopback_only_title": "Kun lokal tilgang",
|
"advanced_loopback_only_title": "Kun lokal tilgang",
|
||||||
"advanced_loopback_warning_before": "Før du aktiverer denne funksjonen, må du sørge for at du har enten:",
|
"advanced_loopback_warning_before": "Før du aktiverer denne funksjonen, må du sørge for at du har enten:",
|
||||||
|
|
@ -101,19 +101,6 @@
|
||||||
"advanced_update_ssh_key_button": "Oppdater SSH-nøkkel",
|
"advanced_update_ssh_key_button": "Oppdater SSH-nøkkel",
|
||||||
"advanced_usb_emulation_description": "Kontroller USB-emuleringstilstanden",
|
"advanced_usb_emulation_description": "Kontroller USB-emuleringstilstanden",
|
||||||
"advanced_usb_emulation_title": "USB-emulering",
|
"advanced_usb_emulation_title": "USB-emulering",
|
||||||
"advanced_version_update_app_label": "Appversjon",
|
|
||||||
"advanced_version_update_button": "Oppdater til versjon",
|
|
||||||
"advanced_version_update_description": "Installer en spesifikk versjon fra GitHub-utgivelser",
|
|
||||||
"advanced_version_update_github_link": "JetKVM-utgivelsesside",
|
|
||||||
"advanced_version_update_helper": "Finn tilgjengelige versjoner på",
|
|
||||||
"advanced_version_update_reset_config_description": "Tilbakestill konfigurasjonen etter oppdateringen",
|
|
||||||
"advanced_version_update_reset_config_label": "Tilbakestill konfigurasjon",
|
|
||||||
"advanced_version_update_system_label": "Systemversjon",
|
|
||||||
"advanced_version_update_target_app": "Kun app",
|
|
||||||
"advanced_version_update_target_both": "Både app og system",
|
|
||||||
"advanced_version_update_target_label": "Hva som skal oppdateres",
|
|
||||||
"advanced_version_update_target_system": "Kun systemet",
|
|
||||||
"advanced_version_update_title": "Oppdatering til spesifikk versjon",
|
|
||||||
"already_adopted_new_owner": "Hvis du er den nye eieren, ber du den forrige eieren om å avregistrere enheten fra kontoen sin i skydashbordet. Hvis du mener dette er en feil, kan du kontakte supportteamet vårt for å få hjelp.",
|
"already_adopted_new_owner": "Hvis du er den nye eieren, ber du den forrige eieren om å avregistrere enheten fra kontoen sin i skydashbordet. Hvis du mener dette er en feil, kan du kontakte supportteamet vårt for å få hjelp.",
|
||||||
"already_adopted_other_user": "Denne enheten er for øyeblikket registrert til en annen bruker i vårt skydashbord.",
|
"already_adopted_other_user": "Denne enheten er for øyeblikket registrert til en annen bruker i vårt skydashbord.",
|
||||||
"already_adopted_return_to_dashboard": "Gå tilbake til dashbordet",
|
"already_adopted_return_to_dashboard": "Gå tilbake til dashbordet",
|
||||||
|
|
@ -134,6 +121,50 @@
|
||||||
"atx_power_control_reset_button": "Tilbakestill",
|
"atx_power_control_reset_button": "Tilbakestill",
|
||||||
"atx_power_control_send_action_error": "Kunne ikke sende ATX-strømhandling {action} : {error}",
|
"atx_power_control_send_action_error": "Kunne ikke sende ATX-strømhandling {action} : {error}",
|
||||||
"atx_power_control_short_power_button": "Kort trykk",
|
"atx_power_control_short_power_button": "Kort trykk",
|
||||||
|
"audio_https_only": "Kun HTTPS",
|
||||||
|
"audio_input_auto_enable_disabled": "Automatisk aktivering av mikrofon deaktivert",
|
||||||
|
"audio_input_auto_enable_enabled": "Automatisk aktivering av mikrofon aktivert",
|
||||||
|
"audio_input_failed_disable": "Kunne ikke deaktivere lydinngang: {error}",
|
||||||
|
"audio_input_failed_enable": "Kunne ikke aktivere lydinngang: {error}",
|
||||||
|
"audio_microphone_description": "Mikrofoninngang til mål",
|
||||||
|
"audio_microphone_title": "Mikrofon",
|
||||||
|
"audio_output_disabled": "Lydutgang deaktivert",
|
||||||
|
"audio_output_enabled": "Lydutgang aktivert",
|
||||||
|
"audio_output_failed_disable": "Kunne ikke deaktivere lydutgang: {error}",
|
||||||
|
"audio_output_failed_enable": "Kunne ikke aktivere lydutgang: {error}",
|
||||||
|
"audio_popover_description": "Raske lydkontroller for høyttalere og mikrofon",
|
||||||
|
"audio_popover_title": "Lyd",
|
||||||
|
"audio_settings_applied": "Lydinnstillinger brukt",
|
||||||
|
"audio_settings_apply_button": "Bruk innstillinger",
|
||||||
|
"audio_settings_auto_enable_microphone_description": "Aktiver automatisk nettlesermikrofon ved tilkobling (ellers må du aktivere det manuelt hver økt)",
|
||||||
|
"audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk",
|
||||||
|
"audio_settings_bitrate_description": "Lydkodingsbitrate (høyere = bedre kvalitet, mer båndbredde)",
|
||||||
|
"audio_settings_bitrate_title": "Opus Bitrate",
|
||||||
|
"audio_settings_buffer_description": "ALSA bufferstørrelse (høyere = mer stabil, mer latens)",
|
||||||
|
"audio_settings_buffer_title": "Bufferperioder",
|
||||||
|
"audio_settings_complexity_description": "Encoder-kompleksitet (0-10, høyere = bedre kvalitet, mer CPU)",
|
||||||
|
"audio_settings_complexity_title": "Opus Kompleksitet",
|
||||||
|
"audio_settings_config_updated": "Lydkonfigurasjon oppdatert",
|
||||||
|
"audio_settings_description": "Konfigurer lydinngangs- og lydutgangsinnstillinger for JetKVM-enheten din",
|
||||||
|
"audio_settings_dtx_description": "Spar båndbredde under stillhet",
|
||||||
|
"audio_settings_dtx_title": "DTX (Diskontinuerlig Overføring)",
|
||||||
|
"audio_settings_fec_description": "Forbedre lydkvaliteten på tapende tilkoblinger",
|
||||||
|
"audio_settings_fec_title": "FEC (Fremadrettet Feilkorreksjon)",
|
||||||
|
"audio_settings_hdmi_label": "HDMI",
|
||||||
|
"audio_settings_output_description": "Aktiver eller deaktiver lyd fra den eksterne datamaskinen",
|
||||||
|
"audio_settings_output_source_description": "Velg lydopptaksenhet (HDMI eller USB)",
|
||||||
|
"audio_settings_output_source_failed": "Kunne ikke angi lydutgangskilde: {error}",
|
||||||
|
"audio_settings_output_source_success": "Lydutgangskilde oppdatert. Lyd starter om 30-60 sekunder.",
|
||||||
|
"audio_settings_output_source_title": "Lydutgangskilde",
|
||||||
|
"audio_settings_output_title": "Lydutgang",
|
||||||
|
"audio_settings_packet_loss_description": "FEC overhead-prosent (høyere = bedre gjenoppretting, mer båndbredde)",
|
||||||
|
"audio_settings_packet_loss_title": "Pakketapskompensasjon",
|
||||||
|
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
|
||||||
|
"audio_settings_sample_rate_title": "Samplingsrate",
|
||||||
|
"audio_settings_title": "Lyd",
|
||||||
|
"audio_settings_usb_label": "USB",
|
||||||
|
"audio_speakers_description": "Lyd fra mål til høyttalere",
|
||||||
|
"audio_speakers_title": "Høyttalere",
|
||||||
"auth_authentication_mode": "Vennligst velg en autentiseringsmodus",
|
"auth_authentication_mode": "Vennligst velg en autentiseringsmodus",
|
||||||
"auth_authentication_mode_error": "Det oppsto en feil under angivelse av autentiseringsmodus",
|
"auth_authentication_mode_error": "Det oppsto en feil under angivelse av autentiseringsmodus",
|
||||||
"auth_authentication_mode_invalid": "Ugyldig autentiseringsmodus",
|
"auth_authentication_mode_invalid": "Ugyldig autentiseringsmodus",
|
||||||
|
|
@ -193,7 +224,6 @@
|
||||||
"connection_stats_remote_ip_address": "Ekstern IP-adresse",
|
"connection_stats_remote_ip_address": "Ekstern IP-adresse",
|
||||||
"connection_stats_remote_ip_address_copy_error": "Kunne ikke kopiere den eksterne IP-adressen",
|
"connection_stats_remote_ip_address_copy_error": "Kunne ikke kopiere den eksterne IP-adressen",
|
||||||
"connection_stats_remote_ip_address_copy_success": "Ekstern IP-adresse { ip } kopiert til utklippstavlen",
|
"connection_stats_remote_ip_address_copy_success": "Ekstern IP-adresse { ip } kopiert til utklippstavlen",
|
||||||
"connection_stats_remote_ip_address_description": "IP-adressen til den eksterne enheten.",
|
|
||||||
"connection_stats_round_trip_time": "Tur-retur-tid",
|
"connection_stats_round_trip_time": "Tur-retur-tid",
|
||||||
"connection_stats_round_trip_time_description": "Rundturstid for det aktive ICE-kandidatparet mellom jevnaldrende.",
|
"connection_stats_round_trip_time_description": "Rundturstid for det aktive ICE-kandidatparet mellom jevnaldrende.",
|
||||||
"connection_stats_sidebar": "Tilkoblingsstatistikk",
|
"connection_stats_sidebar": "Tilkoblingsstatistikk",
|
||||||
|
|
@ -259,7 +289,6 @@
|
||||||
"general_auto_update_description": "Oppdater enheten automatisk til den nyeste versjonen",
|
"general_auto_update_description": "Oppdater enheten automatisk til den nyeste versjonen",
|
||||||
"general_auto_update_error": "Klarte ikke å angi automatisk oppdatering: {error}",
|
"general_auto_update_error": "Klarte ikke å angi automatisk oppdatering: {error}",
|
||||||
"general_auto_update_title": "Automatisk oppdatering",
|
"general_auto_update_title": "Automatisk oppdatering",
|
||||||
"general_check_for_stable_updates": "Nedgrader",
|
|
||||||
"general_check_for_updates": "Se etter oppdateringer",
|
"general_check_for_updates": "Se etter oppdateringer",
|
||||||
"general_page_description": "Konfigurer enhetsinnstillinger og oppdater preferanser",
|
"general_page_description": "Konfigurer enhetsinnstillinger og oppdater preferanser",
|
||||||
"general_reboot_description": "Vil du fortsette med å starte systemet på nytt?",
|
"general_reboot_description": "Vil du fortsette med å starte systemet på nytt?",
|
||||||
|
|
@ -280,13 +309,9 @@
|
||||||
"general_update_checking_title": "Ser etter oppdateringer …",
|
"general_update_checking_title": "Ser etter oppdateringer …",
|
||||||
"general_update_completed_description": "Enheten din er oppdatert til den nyeste versjonen. Kos deg med de nye funksjonene og forbedringene!",
|
"general_update_completed_description": "Enheten din er oppdatert til den nyeste versjonen. Kos deg med de nye funksjonene og forbedringene!",
|
||||||
"general_update_completed_title": "Oppdatering fullført",
|
"general_update_completed_title": "Oppdatering fullført",
|
||||||
"general_update_downgrade_available_description": "En nedgradering er tilgjengelig for å gå tilbake til en tidligere versjon.",
|
|
||||||
"general_update_downgrade_available_title": "Nedgradering tilgjengelig",
|
|
||||||
"general_update_downgrade_button": "Nedgrader nå",
|
|
||||||
"general_update_error_description": "Det oppsto en feil under oppdatering av enheten din. Prøv på nytt senere.",
|
"general_update_error_description": "Det oppsto en feil under oppdatering av enheten din. Prøv på nytt senere.",
|
||||||
"general_update_error_details": "Feildetaljer: {errorMessage}",
|
"general_update_error_details": "Feildetaljer: {errorMessage}",
|
||||||
"general_update_error_title": "Oppdateringsfeil",
|
"general_update_error_title": "Oppdateringsfeil",
|
||||||
"general_update_keep_current_button": "Behold gjeldende versjon",
|
|
||||||
"general_update_later_button": "Oppdater senere",
|
"general_update_later_button": "Oppdater senere",
|
||||||
"general_update_now_button": "Oppdater nå",
|
"general_update_now_button": "Oppdater nå",
|
||||||
"general_update_rebooting": "Starter på nytt for å fullføre oppdateringen …",
|
"general_update_rebooting": "Starter på nytt for å fullføre oppdateringen …",
|
||||||
|
|
@ -302,7 +327,6 @@
|
||||||
"general_update_up_to_date_title": "Alt er oppdatert",
|
"general_update_up_to_date_title": "Alt er oppdatert",
|
||||||
"general_update_updating_description": "Ikke slå av enheten. Denne prosessen kan ta noen minutter.",
|
"general_update_updating_description": "Ikke slå av enheten. Denne prosessen kan ta noen minutter.",
|
||||||
"general_update_updating_title": "Oppdaterer enheten din",
|
"general_update_updating_title": "Oppdaterer enheten din",
|
||||||
"general_update_will_disable_auto_update_description": "Du er i ferd med å endre enhetsversjonen manuelt. Automatisk oppdatering vil bli deaktivert etter at oppdateringen er fullført for å forhindre utilsiktede oppdateringer.",
|
|
||||||
"getting_remote_session_description": "Henter beskrivelse av ekstern øktforsøk {attempt}",
|
"getting_remote_session_description": "Henter beskrivelse av ekstern øktforsøk {attempt}",
|
||||||
"hardware_backlight_settings_error": "Kunne ikke angi innstillinger for bakgrunnsbelysning: {error}",
|
"hardware_backlight_settings_error": "Kunne ikke angi innstillinger for bakgrunnsbelysning: {error}",
|
||||||
"hardware_backlight_settings_get_error": "Klarte ikke å hente bakgrunnsbelysningsinnstillinger: {error}",
|
"hardware_backlight_settings_get_error": "Klarte ikke å hente bakgrunnsbelysningsinnstillinger: {error}",
|
||||||
|
|
@ -817,6 +841,8 @@
|
||||||
"usb_device_description": "USB-enheter som skal emuleres på måldatamaskinen",
|
"usb_device_description": "USB-enheter som skal emuleres på måldatamaskinen",
|
||||||
"usb_device_enable_absolute_mouse_description": "Aktiver absolutt mus (peker)",
|
"usb_device_enable_absolute_mouse_description": "Aktiver absolutt mus (peker)",
|
||||||
"usb_device_enable_absolute_mouse_title": "Aktiver absolutt mus (peker)",
|
"usb_device_enable_absolute_mouse_title": "Aktiver absolutt mus (peker)",
|
||||||
|
"usb_device_enable_audio_description": "Enable bidirectional audio",
|
||||||
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Aktiver tastatur",
|
"usb_device_enable_keyboard_description": "Aktiver tastatur",
|
||||||
"usb_device_enable_keyboard_title": "Aktiver tastatur",
|
"usb_device_enable_keyboard_title": "Aktiver tastatur",
|
||||||
"usb_device_enable_mass_storage_description": "Noen ganger må det kanskje deaktiveres for å forhindre problemer med visse enheter.",
|
"usb_device_enable_mass_storage_description": "Noen ganger må det kanskje deaktiveres for å forhindre problemer med visse enheter.",
|
||||||
|
|
@ -826,6 +852,7 @@
|
||||||
"usb_device_failed_load": "Klarte ikke å laste inn USB-enheter: {error}",
|
"usb_device_failed_load": "Klarte ikke å laste inn USB-enheter: {error}",
|
||||||
"usb_device_failed_set": "Kunne ikke angi USB-enheter: {error}",
|
"usb_device_failed_set": "Kunne ikke angi USB-enheter: {error}",
|
||||||
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, mus og masselagring",
|
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, mus og masselagring",
|
||||||
|
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
|
||||||
"usb_device_keyboard_only": "Kun tastatur",
|
"usb_device_keyboard_only": "Kun tastatur",
|
||||||
"usb_device_restore_default": "Gjenopprett til standard",
|
"usb_device_restore_default": "Gjenopprett til standard",
|
||||||
"usb_device_title": "USB-enhet",
|
"usb_device_title": "USB-enhet",
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
"access_tls_self_signed": "Självsignerad",
|
"access_tls_self_signed": "Självsignerad",
|
||||||
"access_tls_updated": "TLS-inställningarna har uppdaterats",
|
"access_tls_updated": "TLS-inställningarna har uppdaterats",
|
||||||
"access_update_tls_settings": "Uppdatera TLS-inställningar",
|
"access_update_tls_settings": "Uppdatera TLS-inställningar",
|
||||||
"action_bar_connection_stats": "Anslutningsstatistik",
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_extension": "Förlängning",
|
"action_bar_extension": "Förlängning",
|
||||||
"action_bar_fullscreen": "Helskärm",
|
"action_bar_fullscreen": "Helskärm",
|
||||||
"action_bar_settings": "Inställningar",
|
"action_bar_settings": "Inställningar",
|
||||||
|
|
@ -74,7 +74,6 @@
|
||||||
"advanced_error_update_ssh_key": "Misslyckades med att uppdatera SSH-nyckeln: {error}",
|
"advanced_error_update_ssh_key": "Misslyckades med att uppdatera SSH-nyckeln: {error}",
|
||||||
"advanced_error_usb_emulation_disable": "Misslyckades med att inaktivera USB-emulering: {error}",
|
"advanced_error_usb_emulation_disable": "Misslyckades med att inaktivera USB-emulering: {error}",
|
||||||
"advanced_error_usb_emulation_enable": "Misslyckades med att aktivera USB-emulering: {error}",
|
"advanced_error_usb_emulation_enable": "Misslyckades med att aktivera USB-emulering: {error}",
|
||||||
"advanced_error_version_update": "Misslyckades med att initiera versionsuppdatering: {error}",
|
|
||||||
"advanced_loopback_only_description": "Begränsa åtkomst till webbgränssnittet endast till lokal värd (127.0.0.1)",
|
"advanced_loopback_only_description": "Begränsa åtkomst till webbgränssnittet endast till lokal värd (127.0.0.1)",
|
||||||
"advanced_loopback_only_title": "Loopback-läge",
|
"advanced_loopback_only_title": "Loopback-läge",
|
||||||
"advanced_loopback_warning_before": "Innan du aktiverar den här funktionen, se till att du har antingen:",
|
"advanced_loopback_warning_before": "Innan du aktiverar den här funktionen, se till att du har antingen:",
|
||||||
|
|
@ -101,19 +100,6 @@
|
||||||
"advanced_update_ssh_key_button": "Uppdatera SSH-nyckel",
|
"advanced_update_ssh_key_button": "Uppdatera SSH-nyckel",
|
||||||
"advanced_usb_emulation_description": "Kontrollera USB-emuleringsstatusen",
|
"advanced_usb_emulation_description": "Kontrollera USB-emuleringsstatusen",
|
||||||
"advanced_usb_emulation_title": "USB-emulering",
|
"advanced_usb_emulation_title": "USB-emulering",
|
||||||
"advanced_version_update_app_label": "Appversion",
|
|
||||||
"advanced_version_update_button": "Uppdatera till version",
|
|
||||||
"advanced_version_update_description": "Installera en specifik version från GitHub-utgåvor",
|
|
||||||
"advanced_version_update_github_link": "JetKVM-utgåvorsida",
|
|
||||||
"advanced_version_update_helper": "Hitta tillgängliga versioner på",
|
|
||||||
"advanced_version_update_reset_config_description": "Återställ konfigurationen efter uppdateringen",
|
|
||||||
"advanced_version_update_reset_config_label": "Återställ konfigurationen",
|
|
||||||
"advanced_version_update_system_label": "Systemversion",
|
|
||||||
"advanced_version_update_target_app": "Endast app",
|
|
||||||
"advanced_version_update_target_both": "Både app och system",
|
|
||||||
"advanced_version_update_target_label": "Vad som ska uppdateras",
|
|
||||||
"advanced_version_update_target_system": "Endast systemet",
|
|
||||||
"advanced_version_update_title": "Uppdatera till specifik version",
|
|
||||||
"already_adopted_new_owner": "Om du är den nya ägaren ber du den tidigare ägaren att avregistrera enheten från sitt konto i molnöversikten. Om du tror att detta är ett fel kan du kontakta vårt supportteam för hjälp.",
|
"already_adopted_new_owner": "Om du är den nya ägaren ber du den tidigare ägaren att avregistrera enheten från sitt konto i molnöversikten. Om du tror att detta är ett fel kan du kontakta vårt supportteam för hjälp.",
|
||||||
"already_adopted_other_user": "Den här enheten är för närvarande registrerad till en annan användare i vår molnpanel.",
|
"already_adopted_other_user": "Den här enheten är för närvarande registrerad till en annan användare i vår molnpanel.",
|
||||||
"already_adopted_return_to_dashboard": "Återgå till instrumentpanelen",
|
"already_adopted_return_to_dashboard": "Återgå till instrumentpanelen",
|
||||||
|
|
@ -134,6 +120,50 @@
|
||||||
"atx_power_control_reset_button": "Starta om",
|
"atx_power_control_reset_button": "Starta om",
|
||||||
"atx_power_control_send_action_error": "Misslyckades med att skicka ATX-strömåtgärd {action} : {error}",
|
"atx_power_control_send_action_error": "Misslyckades med att skicka ATX-strömåtgärd {action} : {error}",
|
||||||
"atx_power_control_short_power_button": "Kort tryck",
|
"atx_power_control_short_power_button": "Kort tryck",
|
||||||
|
"audio_https_only": "Endast HTTPS",
|
||||||
|
"audio_input_auto_enable_disabled": "Automatisk aktivering av mikrofon inaktiverad",
|
||||||
|
"audio_input_auto_enable_enabled": "Automatisk aktivering av mikrofon aktiverad",
|
||||||
|
"audio_input_failed_disable": "Det gick inte att inaktivera ljudingången: {error}",
|
||||||
|
"audio_input_failed_enable": "Det gick inte att aktivera ljudingången: {error}",
|
||||||
|
"audio_microphone_description": "Mikrofoningång till mål",
|
||||||
|
"audio_microphone_title": "Mikrofon",
|
||||||
|
"audio_output_disabled": "Ljudutgång inaktiverad",
|
||||||
|
"audio_output_enabled": "Ljudutgång aktiverad",
|
||||||
|
"audio_output_failed_disable": "Det gick inte att inaktivera ljudutgången: {error}",
|
||||||
|
"audio_output_failed_enable": "Det gick inte att aktivera ljudutgången: {error}",
|
||||||
|
"audio_popover_description": "Snabba ljudkontroller för högtalare och mikrofon",
|
||||||
|
"audio_popover_title": "Ljud",
|
||||||
|
"audio_settings_applied": "Ljudinställningar tillämpade",
|
||||||
|
"audio_settings_apply_button": "Tillämpa inställningar",
|
||||||
|
"audio_settings_auto_enable_microphone_description": "Aktivera automatiskt webbläsarmikrofon vid anslutning (annars måste du aktivera den manuellt varje session)",
|
||||||
|
"audio_settings_auto_enable_microphone_title": "Aktivera mikrofon automatiskt",
|
||||||
|
"audio_settings_bitrate_description": "Ljudkodningsbitrate (högre = bättre kvalitet, mer bandbredd)",
|
||||||
|
"audio_settings_bitrate_title": "Opus Bitrate",
|
||||||
|
"audio_settings_buffer_description": "ALSA bufferstorlek (högre = mer stabil, mer latens)",
|
||||||
|
"audio_settings_buffer_title": "Bufferperioder",
|
||||||
|
"audio_settings_complexity_description": "Encoder-komplexitet (0-10, högre = bättre kvalitet, mer CPU)",
|
||||||
|
"audio_settings_complexity_title": "Opus Komplexitet",
|
||||||
|
"audio_settings_config_updated": "Ljudkonfiguration uppdaterad",
|
||||||
|
"audio_settings_description": "Konfigurera ljudinmatnings- och ljudutgångsinställningar för din JetKVM-enhet",
|
||||||
|
"audio_settings_dtx_description": "Spara bandbredd under tystnad",
|
||||||
|
"audio_settings_dtx_title": "DTX (Diskontinuerlig Överföring)",
|
||||||
|
"audio_settings_fec_description": "Förbättra ljudkvaliteten på förlustdrabbade anslutningar",
|
||||||
|
"audio_settings_fec_title": "FEC (Framåtriktad Felkorrigering)",
|
||||||
|
"audio_settings_hdmi_label": "HDMI",
|
||||||
|
"audio_settings_output_description": "Aktivera eller inaktivera ljud från fjärrdatorn",
|
||||||
|
"audio_settings_output_source_description": "Välj ljudinspelningsenhet (HDMI eller USB)",
|
||||||
|
"audio_settings_output_source_failed": "Det gick inte att ställa in ljudutgångskälla: {error}",
|
||||||
|
"audio_settings_output_source_success": "Ljudutgångskälla uppdaterad. Ljud startar om 30-60 sekunder.",
|
||||||
|
"audio_settings_output_source_title": "Ljudutgångskälla",
|
||||||
|
"audio_settings_output_title": "Ljudutgång",
|
||||||
|
"audio_settings_packet_loss_description": "FEC overhead-procent (högre = bättre återställning, mer bandbredd)",
|
||||||
|
"audio_settings_packet_loss_title": "Paketförlustkompensation",
|
||||||
|
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
|
||||||
|
"audio_settings_sample_rate_title": "Samplingsfrekvens",
|
||||||
|
"audio_settings_title": "Ljud",
|
||||||
|
"audio_settings_usb_label": "USB",
|
||||||
|
"audio_speakers_description": "Ljud från mål till högtalare",
|
||||||
|
"audio_speakers_title": "Högtalare",
|
||||||
"auth_authentication_mode": "Välj ett autentiseringsläge",
|
"auth_authentication_mode": "Välj ett autentiseringsläge",
|
||||||
"auth_authentication_mode_error": "Ett fel uppstod när autentiseringsläget ställdes in",
|
"auth_authentication_mode_error": "Ett fel uppstod när autentiseringsläget ställdes in",
|
||||||
"auth_authentication_mode_invalid": "Ogiltigt autentiseringsläge",
|
"auth_authentication_mode_invalid": "Ogiltigt autentiseringsläge",
|
||||||
|
|
@ -193,7 +223,6 @@
|
||||||
"connection_stats_remote_ip_address": "Fjärr-IP-adress",
|
"connection_stats_remote_ip_address": "Fjärr-IP-adress",
|
||||||
"connection_stats_remote_ip_address_copy_error": "Misslyckades med att kopiera fjärr-IP-adressen",
|
"connection_stats_remote_ip_address_copy_error": "Misslyckades med att kopiera fjärr-IP-adressen",
|
||||||
"connection_stats_remote_ip_address_copy_success": "Fjärr-IP-adress { ip } kopierad till urklipp",
|
"connection_stats_remote_ip_address_copy_success": "Fjärr-IP-adress { ip } kopierad till urklipp",
|
||||||
"connection_stats_remote_ip_address_description": "IP-adressen för den fjärranslutna enheten.",
|
|
||||||
"connection_stats_round_trip_time": "Tur- och returtid",
|
"connection_stats_round_trip_time": "Tur- och returtid",
|
||||||
"connection_stats_round_trip_time_description": "Tur- och returtid för det aktiva ICE-kandidatparet mellan peers.",
|
"connection_stats_round_trip_time_description": "Tur- och returtid för det aktiva ICE-kandidatparet mellan peers.",
|
||||||
"connection_stats_sidebar": "Anslutningsstatistik",
|
"connection_stats_sidebar": "Anslutningsstatistik",
|
||||||
|
|
@ -259,7 +288,6 @@
|
||||||
"general_auto_update_description": "Uppdatera enheten automatiskt till den senaste versionen",
|
"general_auto_update_description": "Uppdatera enheten automatiskt till den senaste versionen",
|
||||||
"general_auto_update_error": "Misslyckades med att ställa in automatisk uppdatering: {error}",
|
"general_auto_update_error": "Misslyckades med att ställa in automatisk uppdatering: {error}",
|
||||||
"general_auto_update_title": "Automatisk uppdatering",
|
"general_auto_update_title": "Automatisk uppdatering",
|
||||||
"general_check_for_stable_updates": "Nedvärdera",
|
|
||||||
"general_check_for_updates": "Kontrollera efter uppdateringar",
|
"general_check_for_updates": "Kontrollera efter uppdateringar",
|
||||||
"general_page_description": "Konfigurera enhetsinställningar och uppdatera inställningar",
|
"general_page_description": "Konfigurera enhetsinställningar och uppdatera inställningar",
|
||||||
"general_reboot_description": "Vill du fortsätta med att starta om systemet?",
|
"general_reboot_description": "Vill du fortsätta med att starta om systemet?",
|
||||||
|
|
@ -280,13 +308,9 @@
|
||||||
"general_update_checking_title": "Söker efter uppdateringar…",
|
"general_update_checking_title": "Söker efter uppdateringar…",
|
||||||
"general_update_completed_description": "Din enhet har uppdaterats till den senaste versionen. Njut av de nya funktionerna och förbättringarna!",
|
"general_update_completed_description": "Din enhet har uppdaterats till den senaste versionen. Njut av de nya funktionerna och förbättringarna!",
|
||||||
"general_update_completed_title": "Uppdateringen är slutförd",
|
"general_update_completed_title": "Uppdateringen är slutförd",
|
||||||
"general_update_downgrade_available_description": "En nedgradering är tillgänglig för att återgå till en tidigare version.",
|
|
||||||
"general_update_downgrade_available_title": "Nedgradering tillgänglig",
|
|
||||||
"general_update_downgrade_button": "Nedgradera nu",
|
|
||||||
"general_update_error_description": "Ett fel uppstod när enheten uppdaterades. Försök igen senare.",
|
"general_update_error_description": "Ett fel uppstod när enheten uppdaterades. Försök igen senare.",
|
||||||
"general_update_error_details": "Felinformation: {errorMessage}",
|
"general_update_error_details": "Felinformation: {errorMessage}",
|
||||||
"general_update_error_title": "Uppdateringsfel",
|
"general_update_error_title": "Uppdateringsfel",
|
||||||
"general_update_keep_current_button": "Behåll aktuell version",
|
|
||||||
"general_update_later_button": "Gör det senare",
|
"general_update_later_button": "Gör det senare",
|
||||||
"general_update_now_button": "Uppdatera nu",
|
"general_update_now_button": "Uppdatera nu",
|
||||||
"general_update_rebooting": "Startar om för att slutföra uppdateringen…",
|
"general_update_rebooting": "Startar om för att slutföra uppdateringen…",
|
||||||
|
|
@ -302,7 +326,6 @@
|
||||||
"general_update_up_to_date_title": "Systemet är uppdaterat",
|
"general_update_up_to_date_title": "Systemet är uppdaterat",
|
||||||
"general_update_updating_description": "Stäng inte av enheten. Den här processen kan ta några minuter.",
|
"general_update_updating_description": "Stäng inte av enheten. Den här processen kan ta några minuter.",
|
||||||
"general_update_updating_title": "Uppdaterar din enhet",
|
"general_update_updating_title": "Uppdaterar din enhet",
|
||||||
"general_update_will_disable_auto_update_description": "Du håller på att ändra din enhetsversion manuellt. Automatisk uppdatering inaktiveras efter att uppdateringen är klar för att förhindra oavsiktliga uppdateringar.",
|
|
||||||
"getting_remote_session_description": "Hämtar beskrivning av fjärrsession försök {attempt}",
|
"getting_remote_session_description": "Hämtar beskrivning av fjärrsession försök {attempt}",
|
||||||
"hardware_backlight_settings_error": "Misslyckades med att ställa in bakgrundsbelysning: {error}",
|
"hardware_backlight_settings_error": "Misslyckades med att ställa in bakgrundsbelysning: {error}",
|
||||||
"hardware_backlight_settings_get_error": "Misslyckades med att hämta inställningar för bakgrundsbelysning: {error}",
|
"hardware_backlight_settings_get_error": "Misslyckades med att hämta inställningar för bakgrundsbelysning: {error}",
|
||||||
|
|
@ -817,6 +840,8 @@
|
||||||
"usb_device_description": "USB-enheter att emulera på måldatorn",
|
"usb_device_description": "USB-enheter att emulera på måldatorn",
|
||||||
"usb_device_enable_absolute_mouse_description": "Aktivera absolut mus (pekare)",
|
"usb_device_enable_absolute_mouse_description": "Aktivera absolut mus (pekare)",
|
||||||
"usb_device_enable_absolute_mouse_title": "Aktivera absolut mus (pekare)",
|
"usb_device_enable_absolute_mouse_title": "Aktivera absolut mus (pekare)",
|
||||||
|
"usb_device_enable_audio_description": "Enable bidirectional audio",
|
||||||
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Aktivera tangentbord",
|
"usb_device_enable_keyboard_description": "Aktivera tangentbord",
|
||||||
"usb_device_enable_keyboard_title": "Aktivera tangentbord",
|
"usb_device_enable_keyboard_title": "Aktivera tangentbord",
|
||||||
"usb_device_enable_mass_storage_description": "Ibland kan det behöva inaktiveras för att förhindra problem med vissa enheter.",
|
"usb_device_enable_mass_storage_description": "Ibland kan det behöva inaktiveras för att förhindra problem med vissa enheter.",
|
||||||
|
|
@ -826,6 +851,7 @@
|
||||||
"usb_device_failed_load": "Misslyckades med att ladda USB-enheter: {error}",
|
"usb_device_failed_load": "Misslyckades med att ladda USB-enheter: {error}",
|
||||||
"usb_device_failed_set": "Misslyckades med att ställa in USB-enheter: {error}",
|
"usb_device_failed_set": "Misslyckades med att ställa in USB-enheter: {error}",
|
||||||
"usb_device_keyboard_mouse_and_mass_storage": "Tangentbord, mus och masslagring",
|
"usb_device_keyboard_mouse_and_mass_storage": "Tangentbord, mus och masslagring",
|
||||||
|
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
|
||||||
"usb_device_keyboard_only": "Endast tangentbord",
|
"usb_device_keyboard_only": "Endast tangentbord",
|
||||||
"usb_device_restore_default": "Återställ till standard",
|
"usb_device_restore_default": "Återställ till standard",
|
||||||
"usb_device_title": "USB-enhet",
|
"usb_device_title": "USB-enhet",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"access_tls_self_signed": "自签名",
|
"access_tls_self_signed": "自签名",
|
||||||
"access_tls_updated": "TLS 设置更新成功",
|
"access_tls_updated": "TLS 设置更新成功",
|
||||||
"access_update_tls_settings": "更新 TLS 设置",
|
"access_update_tls_settings": "更新 TLS 设置",
|
||||||
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "连接统计",
|
"action_bar_connection_stats": "连接统计",
|
||||||
"action_bar_extension": "扩展",
|
"action_bar_extension": "扩展",
|
||||||
"action_bar_fullscreen": "全屏",
|
"action_bar_fullscreen": "全屏",
|
||||||
|
|
@ -74,7 +75,6 @@
|
||||||
"advanced_error_update_ssh_key": "无法更新 SSH 密钥: {error}",
|
"advanced_error_update_ssh_key": "无法更新 SSH 密钥: {error}",
|
||||||
"advanced_error_usb_emulation_disable": "无法禁用 USB 仿真: {error}",
|
"advanced_error_usb_emulation_disable": "无法禁用 USB 仿真: {error}",
|
||||||
"advanced_error_usb_emulation_enable": "无法启用 USB 仿真: {error}",
|
"advanced_error_usb_emulation_enable": "无法启用 USB 仿真: {error}",
|
||||||
"advanced_error_version_update": "版本更新失败: {error}",
|
|
||||||
"advanced_loopback_only_description": "限制 Web 界面仅可访问本地主机(127.0.0.1)",
|
"advanced_loopback_only_description": "限制 Web 界面仅可访问本地主机(127.0.0.1)",
|
||||||
"advanced_loopback_only_title": "仅环回模式",
|
"advanced_loopback_only_title": "仅环回模式",
|
||||||
"advanced_loopback_warning_before": "在启用此功能之前,请确保您已:",
|
"advanced_loopback_warning_before": "在启用此功能之前,请确保您已:",
|
||||||
|
|
@ -101,19 +101,6 @@
|
||||||
"advanced_update_ssh_key_button": "更新 SSH 密钥",
|
"advanced_update_ssh_key_button": "更新 SSH 密钥",
|
||||||
"advanced_usb_emulation_description": "控制 USB 仿真状态",
|
"advanced_usb_emulation_description": "控制 USB 仿真状态",
|
||||||
"advanced_usb_emulation_title": "USB 仿真",
|
"advanced_usb_emulation_title": "USB 仿真",
|
||||||
"advanced_version_update_app_label": "应用版本",
|
|
||||||
"advanced_version_update_button": "更新至版本",
|
|
||||||
"advanced_version_update_description": "从 GitHub 发布页面安装特定版本",
|
|
||||||
"advanced_version_update_github_link": "JetKVM 发布页面",
|
|
||||||
"advanced_version_update_helper": "在以下平台查找可用版本",
|
|
||||||
"advanced_version_update_reset_config_description": "更新后重置配置",
|
|
||||||
"advanced_version_update_reset_config_label": "重置配置",
|
|
||||||
"advanced_version_update_system_label": "系统版本",
|
|
||||||
"advanced_version_update_target_app": "仅限应用内购买",
|
|
||||||
"advanced_version_update_target_both": "应用程序和系统",
|
|
||||||
"advanced_version_update_target_label": "需要更新什么",
|
|
||||||
"advanced_version_update_target_system": "仅系统",
|
|
||||||
"advanced_version_update_title": "更新至特定版本",
|
|
||||||
"already_adopted_new_owner": "如果您是新用户,请要求前用户在云端控制面板中从其帐户中取消注册该设备。如果您认为此操作有误,请联系我们的支持团队寻求帮助。",
|
"already_adopted_new_owner": "如果您是新用户,请要求前用户在云端控制面板中从其帐户中取消注册该设备。如果您认为此操作有误,请联系我们的支持团队寻求帮助。",
|
||||||
"already_adopted_other_user": "该设备目前已在我们的云仪表板中注册给另一个用户。",
|
"already_adopted_other_user": "该设备目前已在我们的云仪表板中注册给另一个用户。",
|
||||||
"already_adopted_return_to_dashboard": "返回仪表板",
|
"already_adopted_return_to_dashboard": "返回仪表板",
|
||||||
|
|
@ -134,6 +121,50 @@
|
||||||
"atx_power_control_reset_button": "重置",
|
"atx_power_control_reset_button": "重置",
|
||||||
"atx_power_control_send_action_error": "无法发送 ATX 电源操作 {action} : {error}",
|
"atx_power_control_send_action_error": "无法发送 ATX 电源操作 {action} : {error}",
|
||||||
"atx_power_control_short_power_button": "短按",
|
"atx_power_control_short_power_button": "短按",
|
||||||
|
"audio_https_only": "仅限 HTTPS",
|
||||||
|
"audio_input_auto_enable_disabled": "自动启用麦克风已禁用",
|
||||||
|
"audio_input_auto_enable_enabled": "自动启用麦克风已启用",
|
||||||
|
"audio_input_failed_disable": "禁用音频输入失败:{error}",
|
||||||
|
"audio_input_failed_enable": "启用音频输入失败:{error}",
|
||||||
|
"audio_microphone_description": "麦克风输入到目标设备",
|
||||||
|
"audio_microphone_title": "麦克风",
|
||||||
|
"audio_output_disabled": "音频输出已禁用",
|
||||||
|
"audio_output_enabled": "音频输出已启用",
|
||||||
|
"audio_output_failed_disable": "禁用音频输出失败:{error}",
|
||||||
|
"audio_output_failed_enable": "启用音频输出失败:{error}",
|
||||||
|
"audio_popover_description": "扬声器和麦克风的快速音频控制",
|
||||||
|
"audio_popover_title": "音频",
|
||||||
|
"audio_settings_applied": "音频设置已应用",
|
||||||
|
"audio_settings_apply_button": "应用设置",
|
||||||
|
"audio_settings_auto_enable_microphone_description": "连接时自动启用浏览器麦克风(否则您必须在每次会话中手动启用)",
|
||||||
|
"audio_settings_auto_enable_microphone_title": "自动启用麦克风",
|
||||||
|
"audio_settings_bitrate_description": "音频编码比特率(越高 = 质量越好,带宽越大)",
|
||||||
|
"audio_settings_bitrate_title": "Opus 比特率",
|
||||||
|
"audio_settings_buffer_description": "ALSA 缓冲大小(越高 = 越稳定,延迟越高)",
|
||||||
|
"audio_settings_buffer_title": "缓冲周期",
|
||||||
|
"audio_settings_complexity_description": "编码器复杂度(0-10,越高 = 质量越好,CPU 使用越多)",
|
||||||
|
"audio_settings_complexity_title": "Opus 复杂度",
|
||||||
|
"audio_settings_config_updated": "音频配置已更新",
|
||||||
|
"audio_settings_description": "配置 JetKVM 设备的音频输入和输出设置",
|
||||||
|
"audio_settings_dtx_description": "在静音时节省带宽",
|
||||||
|
"audio_settings_dtx_title": "DTX(不连续传输)",
|
||||||
|
"audio_settings_fec_description": "改善有损连接上的音频质量",
|
||||||
|
"audio_settings_fec_title": "FEC(前向纠错)",
|
||||||
|
"audio_settings_hdmi_label": "HDMI",
|
||||||
|
"audio_settings_output_description": "启用或禁用来自远程计算机的音频",
|
||||||
|
"audio_settings_output_source_description": "选择音频捕获设备(HDMI 或 USB)",
|
||||||
|
"audio_settings_output_source_failed": "设置音频输出源失败:{error}",
|
||||||
|
"audio_settings_output_source_success": "音频输出源已更新。音频将在30-60秒内启动。",
|
||||||
|
"audio_settings_output_source_title": "音频输出源",
|
||||||
|
"audio_settings_output_title": "音频输出",
|
||||||
|
"audio_settings_packet_loss_description": "FEC 开销百分比(越高 = 恢复越好,带宽越大)",
|
||||||
|
"audio_settings_packet_loss_title": "丢包补偿",
|
||||||
|
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
|
||||||
|
"audio_settings_sample_rate_title": "采样率",
|
||||||
|
"audio_settings_title": "音频",
|
||||||
|
"audio_settings_usb_label": "USB",
|
||||||
|
"audio_speakers_description": "从目标设备到扬声器的音频",
|
||||||
|
"audio_speakers_title": "扬声器",
|
||||||
"auth_authentication_mode": "请选择身份验证方式",
|
"auth_authentication_mode": "请选择身份验证方式",
|
||||||
"auth_authentication_mode_error": "设置身份验证模式时发生错误",
|
"auth_authentication_mode_error": "设置身份验证模式时发生错误",
|
||||||
"auth_authentication_mode_invalid": "身份验证模式无效",
|
"auth_authentication_mode_invalid": "身份验证模式无效",
|
||||||
|
|
@ -190,10 +221,9 @@
|
||||||
"connection_stats_packets_lost_description": "丢失的入站视频 RTP 数据包的数量。",
|
"connection_stats_packets_lost_description": "丢失的入站视频 RTP 数据包的数量。",
|
||||||
"connection_stats_playback_delay": "播放延迟",
|
"connection_stats_playback_delay": "播放延迟",
|
||||||
"connection_stats_playback_delay_description": "当帧不均匀到达时,抖动缓冲区添加延迟以平滑播放。",
|
"connection_stats_playback_delay_description": "当帧不均匀到达时,抖动缓冲区添加延迟以平滑播放。",
|
||||||
"connection_stats_remote_ip_address": "远程 IP 地址",
|
"connection_stats_remote_ip_address": "远程IP地址",
|
||||||
"connection_stats_remote_ip_address_copy_error": "复制远程 IP 地址失败",
|
"connection_stats_remote_ip_address_copy_error": "复制远程 IP 地址失败",
|
||||||
"connection_stats_remote_ip_address_copy_success": "远程 IP 地址{ ip }已复制到剪贴板",
|
"connection_stats_remote_ip_address_copy_success": "远程 IP 地址{ ip }已复制到剪贴板",
|
||||||
"connection_stats_remote_ip_address_description": "远程设备的IP地址。",
|
|
||||||
"connection_stats_round_trip_time": "往返时间",
|
"connection_stats_round_trip_time": "往返时间",
|
||||||
"connection_stats_round_trip_time_description": "对等体之间活跃 ICE 候选对的往返时间。",
|
"connection_stats_round_trip_time_description": "对等体之间活跃 ICE 候选对的往返时间。",
|
||||||
"connection_stats_sidebar": "连接统计",
|
"connection_stats_sidebar": "连接统计",
|
||||||
|
|
@ -259,7 +289,6 @@
|
||||||
"general_auto_update_description": "自动将设备更新到最新版本",
|
"general_auto_update_description": "自动将设备更新到最新版本",
|
||||||
"general_auto_update_error": "无法设置自动更新: {error}",
|
"general_auto_update_error": "无法设置自动更新: {error}",
|
||||||
"general_auto_update_title": "自动更新",
|
"general_auto_update_title": "自动更新",
|
||||||
"general_check_for_stable_updates": "降级",
|
|
||||||
"general_check_for_updates": "检查更新",
|
"general_check_for_updates": "检查更新",
|
||||||
"general_page_description": "配置设备设置并更新首选项",
|
"general_page_description": "配置设备设置并更新首选项",
|
||||||
"general_reboot_description": "您想继续重新启动系统吗?",
|
"general_reboot_description": "您想继续重新启动系统吗?",
|
||||||
|
|
@ -280,13 +309,9 @@
|
||||||
"general_update_checking_title": "正在检查更新…",
|
"general_update_checking_title": "正在检查更新…",
|
||||||
"general_update_completed_description": "您的设备已成功更新至最新版本。尽情享受新功能和改进吧!",
|
"general_update_completed_description": "您的设备已成功更新至最新版本。尽情享受新功能和改进吧!",
|
||||||
"general_update_completed_title": "更新已成功完成",
|
"general_update_completed_title": "更新已成功完成",
|
||||||
"general_update_downgrade_available_description": "可以降级到以前的版本。",
|
|
||||||
"general_update_downgrade_available_title": "可降级",
|
|
||||||
"general_update_downgrade_button": "立即降级",
|
|
||||||
"general_update_error_description": "更新您的设备时出错。请稍后重试。",
|
"general_update_error_description": "更新您的设备时出错。请稍后重试。",
|
||||||
"general_update_error_details": "错误详细信息: {errorMessage}",
|
"general_update_error_details": "错误详细信息: {errorMessage}",
|
||||||
"general_update_error_title": "更新错误",
|
"general_update_error_title": "更新错误",
|
||||||
"general_update_keep_current_button": "保持当前版本",
|
|
||||||
"general_update_later_button": "稍后再说",
|
"general_update_later_button": "稍后再说",
|
||||||
"general_update_now_button": "立即更新",
|
"general_update_now_button": "立即更新",
|
||||||
"general_update_rebooting": "重新启动以完成更新…",
|
"general_update_rebooting": "重新启动以完成更新…",
|
||||||
|
|
@ -302,7 +327,6 @@
|
||||||
"general_update_up_to_date_title": "系统已更新",
|
"general_update_up_to_date_title": "系统已更新",
|
||||||
"general_update_updating_description": "正在更新,请勿关闭设备。该过程可能需要数分钟。",
|
"general_update_updating_description": "正在更新,请勿关闭设备。该过程可能需要数分钟。",
|
||||||
"general_update_updating_title": "更新您的设备",
|
"general_update_updating_title": "更新您的设备",
|
||||||
"general_update_will_disable_auto_update_description": "您即将手动更改设备版本。更新完成后,自动更新功能将被禁用,以防止意外更新。",
|
|
||||||
"getting_remote_session_description": "获取远程会话描述尝试 {attempt}",
|
"getting_remote_session_description": "获取远程会话描述尝试 {attempt}",
|
||||||
"hardware_backlight_settings_error": "无法设置背光设置: {error}",
|
"hardware_backlight_settings_error": "无法设置背光设置: {error}",
|
||||||
"hardware_backlight_settings_get_error": "无法获取背光设置: {error}",
|
"hardware_backlight_settings_get_error": "无法获取背光设置: {error}",
|
||||||
|
|
@ -817,6 +841,8 @@
|
||||||
"usb_device_description": "在目标计算机上仿真的 USB 设备",
|
"usb_device_description": "在目标计算机上仿真的 USB 设备",
|
||||||
"usb_device_enable_absolute_mouse_description": "启用绝对鼠标(指针)",
|
"usb_device_enable_absolute_mouse_description": "启用绝对鼠标(指针)",
|
||||||
"usb_device_enable_absolute_mouse_title": "启用绝对鼠标(指针)",
|
"usb_device_enable_absolute_mouse_title": "启用绝对鼠标(指针)",
|
||||||
|
"usb_device_enable_audio_description": "Enable bidirectional audio",
|
||||||
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "启用键盘",
|
"usb_device_enable_keyboard_description": "启用键盘",
|
||||||
"usb_device_enable_keyboard_title": "启用键盘",
|
"usb_device_enable_keyboard_title": "启用键盘",
|
||||||
"usb_device_enable_mass_storage_description": "有时可能需要禁用它以防止某些设备出现问题",
|
"usb_device_enable_mass_storage_description": "有时可能需要禁用它以防止某些设备出现问题",
|
||||||
|
|
@ -826,6 +852,7 @@
|
||||||
"usb_device_failed_load": "无法加载 USB 设备: {error}",
|
"usb_device_failed_load": "无法加载 USB 设备: {error}",
|
||||||
"usb_device_failed_set": "无法设置 USB 设备: {error}",
|
"usb_device_failed_set": "无法设置 USB 设备: {error}",
|
||||||
"usb_device_keyboard_mouse_and_mass_storage": "键盘、鼠标和大容量存储器",
|
"usb_device_keyboard_mouse_and_mass_storage": "键盘、鼠标和大容量存储器",
|
||||||
|
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
|
||||||
"usb_device_keyboard_only": "仅限键盘",
|
"usb_device_keyboard_only": "仅限键盘",
|
||||||
"usb_device_restore_default": "恢复默认设置",
|
"usb_device_restore_default": "恢复默认设置",
|
||||||
"usb_device_title": "USB 设备",
|
"usb_device_title": "USB 设备",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Fragment, useCallback, useRef } from "react";
|
import { Fragment, useCallback, useRef } from "react";
|
||||||
import { MdOutlineContentPasteGo } from "react-icons/md";
|
import { MdOutlineContentPasteGo } from "react-icons/md";
|
||||||
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal, LuVolume2 } from "react-icons/lu";
|
||||||
import { FaKeyboard } from "react-icons/fa6";
|
import { FaKeyboard } from "react-icons/fa6";
|
||||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
@ -19,6 +19,7 @@ import PasteModal from "@components/popovers/PasteModal";
|
||||||
import WakeOnLanModal from "@components/popovers/WakeOnLan/Index";
|
import WakeOnLanModal from "@components/popovers/WakeOnLan/Index";
|
||||||
import MountPopopover from "@components/popovers/MountPopover";
|
import MountPopopover from "@components/popovers/MountPopover";
|
||||||
import ExtensionPopover from "@components/popovers/ExtensionPopover";
|
import ExtensionPopover from "@components/popovers/ExtensionPopover";
|
||||||
|
import AudioPopover from "@components/popovers/AudioPopover";
|
||||||
import { m } from "@localizations/messages.js";
|
import { m } from "@localizations/messages.js";
|
||||||
|
|
||||||
export default function Actionbar({
|
export default function Actionbar({
|
||||||
|
|
@ -201,6 +202,36 @@ export default function Actionbar({
|
||||||
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Popover>
|
||||||
|
<PopoverButton as={Fragment}>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text={m.action_bar_audio()}
|
||||||
|
LeadingIcon={LuVolume2}
|
||||||
|
onClick={() => {
|
||||||
|
setDisableVideoFocusTrap(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverButton>
|
||||||
|
<PopoverPanel
|
||||||
|
anchor="bottom start"
|
||||||
|
transition
|
||||||
|
className={cx(
|
||||||
|
"z-10 flex w-[420px] flex-col overflow-visible!",
|
||||||
|
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ open }) => {
|
||||||
|
checkIfStateChanged(open);
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl">
|
||||||
|
<AudioPopover />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,45 @@
|
||||||
import { cx } from "@/cva.config";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import { cva, cx } from "@/cva.config";
|
||||||
import LoadingSpinner from "@components/LoadingSpinner";
|
import LoadingSpinner from "@components/LoadingSpinner";
|
||||||
|
|
||||||
|
const badgeVariants = cva({
|
||||||
|
base: "ml-2 rounded-full px-2 py-1 text-[10px] font-medium leading-none text-white dark:border",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
error: "bg-red-500 dark:border-red-700 dark:bg-red-800 dark:text-red-50",
|
||||||
|
info: "bg-blue-500 dark:border-blue-600 dark:bg-blue-700 dark:text-blue-50",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
interface SettingsItemProps {
|
interface SettingsItemProps {
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly description: string | React.ReactNode;
|
readonly description: string | React.ReactNode;
|
||||||
readonly badge?: string;
|
readonly badge?: string;
|
||||||
readonly badgeTheme?: keyof typeof badgeTheme;
|
readonly badgeVariant?: "error" | "info";
|
||||||
|
readonly badgeLink?: string;
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
readonly loading?: boolean;
|
readonly loading?: boolean;
|
||||||
readonly children?: React.ReactNode;
|
readonly children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const badgeTheme = {
|
|
||||||
info: "bg-blue-500 text-white",
|
|
||||||
success: "bg-green-500 text-white",
|
|
||||||
warning: "bg-yellow-500 text-white",
|
|
||||||
danger: "bg-red-500 text-white",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SettingsItem(props: SettingsItemProps) {
|
export function SettingsItem(props: SettingsItemProps) {
|
||||||
const { title, description, badge, badgeTheme: badgeThemeProp = "danger", children, className, loading } = props;
|
const { title, description, badge, badgeVariant = "error", badgeLink, children, className, loading } = props;
|
||||||
const badgeThemeClass = badgeTheme[badgeThemeProp];
|
|
||||||
|
const badgeClasses = badgeVariants({ variant: badgeVariant });
|
||||||
|
|
||||||
|
const badgeContent = badge && (
|
||||||
|
badgeLink ? (
|
||||||
|
<Link to={badgeLink} className={cx(badgeClasses, "hover:opacity-80 transition-opacity cursor-pointer")}>
|
||||||
|
{badge}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className={badgeClasses}>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
|
|
@ -33,11 +52,7 @@ export function SettingsItem(props: SettingsItemProps) {
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<div className="flex items-center text-base font-semibold text-black dark:text-white">
|
<div className="flex items-center text-base font-semibold text-black dark:text-white">
|
||||||
{title}
|
{title}
|
||||||
{badge && (
|
{badgeContent}
|
||||||
<span className={cx("ml-2 rounded-full px-2 py-1 text-[10px] font-medium leading-none text-white", badgeThemeClass)}>
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{loading && <LoadingSpinner className="h-4 w-4 text-blue-500" />}
|
{loading && <LoadingSpinner className="h-4 w-4 text-blue-500" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export interface UsbDeviceConfig {
|
||||||
absolute_mouse: boolean;
|
absolute_mouse: boolean;
|
||||||
relative_mouse: boolean;
|
relative_mouse: boolean;
|
||||||
mass_storage: boolean;
|
mass_storage: boolean;
|
||||||
|
audio: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultUsbDeviceConfig: UsbDeviceConfig = {
|
const defaultUsbDeviceConfig: UsbDeviceConfig = {
|
||||||
|
|
@ -31,17 +32,30 @@ const defaultUsbDeviceConfig: UsbDeviceConfig = {
|
||||||
absolute_mouse: true,
|
absolute_mouse: true,
|
||||||
relative_mouse: true,
|
relative_mouse: true,
|
||||||
mass_storage: true,
|
mass_storage: true,
|
||||||
|
audio: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const usbPresets = [
|
const usbPresets = [
|
||||||
{
|
{
|
||||||
label: m.usb_device_keyboard_mouse_and_mass_storage(),
|
label: m.usb_device_keyboard_mouse_mass_storage_and_audio(),
|
||||||
value: "default",
|
value: "default",
|
||||||
config: {
|
config: {
|
||||||
keyboard: true,
|
keyboard: true,
|
||||||
absolute_mouse: true,
|
absolute_mouse: true,
|
||||||
relative_mouse: true,
|
relative_mouse: true,
|
||||||
mass_storage: true,
|
mass_storage: true,
|
||||||
|
audio: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.usb_device_keyboard_mouse_and_mass_storage(),
|
||||||
|
value: "keyboard_mouse_and_mass_storage",
|
||||||
|
config: {
|
||||||
|
keyboard: true,
|
||||||
|
absolute_mouse: true,
|
||||||
|
relative_mouse: true,
|
||||||
|
mass_storage: true,
|
||||||
|
audio: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -52,6 +66,7 @@ const usbPresets = [
|
||||||
absolute_mouse: false,
|
absolute_mouse: false,
|
||||||
relative_mouse: false,
|
relative_mouse: false,
|
||||||
mass_storage: false,
|
mass_storage: false,
|
||||||
|
audio: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -219,6 +234,17 @@ export function UsbDeviceSetting() {
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsItem
|
||||||
|
title={m.usb_device_enable_audio_title()}
|
||||||
|
description={m.usb_device_enable_audio_description()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={usbDeviceConfig.audio}
|
||||||
|
onChange={onUsbConfigItemChange("audio")}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex gap-x-2">
|
<div className="mt-6 flex gap-x-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -22,17 +22,20 @@ import {
|
||||||
import { keys } from "@/keyboardMappings";
|
import { keys } from "@/keyboardMappings";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { m } from "@localizations/messages.js";
|
import { m } from "@localizations/messages.js";
|
||||||
|
import { isSecureContext } from "@/utils";
|
||||||
|
|
||||||
export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssues: boolean }) {
|
export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssues: boolean }) {
|
||||||
// Video and stream related refs and states
|
// Video and stream related refs and states
|
||||||
const videoElm = useRef<HTMLVideoElement>(null);
|
const videoElm = useRef<HTMLVideoElement>(null);
|
||||||
|
const audioElementsRef = useRef<HTMLAudioElement[]>([]);
|
||||||
const fullscreenContainerRef = useRef<HTMLDivElement>(null);
|
const fullscreenContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const { mediaStream, peerConnectionState } = useRTCStore();
|
const { mediaStream, peerConnectionState } = useRTCStore();
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [audioAutoplayBlocked, setAudioAutoplayBlocked] = useState(false);
|
||||||
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
|
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
|
||||||
const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false);
|
const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false);
|
||||||
|
|
||||||
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
|
const isPointerLockPossible = isSecureContext();
|
||||||
|
|
||||||
// Store hooks
|
// Store hooks
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
|
|
@ -335,13 +338,34 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
||||||
peerConnection.addEventListener(
|
peerConnection.addEventListener(
|
||||||
"track",
|
"track",
|
||||||
(e: RTCTrackEvent) => {
|
(e: RTCTrackEvent) => {
|
||||||
addStreamToVideoElm(e.streams[0]);
|
if (e.track.kind === "video") {
|
||||||
|
addStreamToVideoElm(e.streams[0]);
|
||||||
|
} else if (e.track.kind === "audio") {
|
||||||
|
const audioElm = document.createElement("audio");
|
||||||
|
audioElm.srcObject = e.streams[0];
|
||||||
|
audioElm.style.display = "none";
|
||||||
|
document.body.appendChild(audioElm);
|
||||||
|
audioElementsRef.current.push(audioElm);
|
||||||
|
|
||||||
|
audioElm.play().then(() => {
|
||||||
|
setAudioAutoplayBlocked(false);
|
||||||
|
}).catch(() => {
|
||||||
|
console.debug("[Audio] Autoplay blocked, will be started by user interaction");
|
||||||
|
setAudioAutoplayBlocked(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
|
audioElementsRef.current.forEach((audioElm) => {
|
||||||
|
audioElm.srcObject = null;
|
||||||
|
audioElm.remove();
|
||||||
|
});
|
||||||
|
audioElementsRef.current = [];
|
||||||
|
setAudioAutoplayBlocked(false);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[addStreamToVideoElm, peerConnection],
|
[addStreamToVideoElm, peerConnection],
|
||||||
|
|
@ -455,11 +479,12 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
||||||
|
|
||||||
const hasNoAutoPlayPermissions = useMemo(() => {
|
const hasNoAutoPlayPermissions = useMemo(() => {
|
||||||
if (peerConnection?.connectionState !== "connected") return false;
|
if (peerConnection?.connectionState !== "connected") return false;
|
||||||
if (isPlaying) return false;
|
|
||||||
if (hdmiError) return false;
|
if (hdmiError) return false;
|
||||||
if (videoHeight === 0 || videoWidth === 0) return false;
|
if (videoHeight === 0 || videoWidth === 0) return false;
|
||||||
return true;
|
if (!isPlaying) return true;
|
||||||
}, [hdmiError, isPlaying, peerConnection?.connectionState, videoHeight, videoWidth]);
|
if (audioAutoplayBlocked) return true;
|
||||||
|
return false;
|
||||||
|
}, [audioAutoplayBlocked, hdmiError, isPlaying, peerConnection?.connectionState, videoHeight, videoWidth]);
|
||||||
|
|
||||||
const showPointerLockBar = useMemo(() => {
|
const showPointerLockBar = useMemo(() => {
|
||||||
if (settings.mouseMode !== "relative") return false;
|
if (settings.mouseMode !== "relative") return false;
|
||||||
|
|
@ -523,7 +548,6 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
||||||
controls={false}
|
controls={false}
|
||||||
onPlaying={onVideoPlaying}
|
onPlaying={onVideoPlaying}
|
||||||
onPlay={onVideoPlaying}
|
onPlay={onVideoPlaying}
|
||||||
muted
|
|
||||||
playsInline
|
playsInline
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
controlsList="nofullscreen"
|
controlsList="nofullscreen"
|
||||||
|
|
@ -555,6 +579,11 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
||||||
show={hasNoAutoPlayPermissions}
|
show={hasNoAutoPlayPermissions}
|
||||||
onPlayClick={() => {
|
onPlayClick={() => {
|
||||||
videoElm.current?.play();
|
videoElm.current?.play();
|
||||||
|
audioElementsRef.current.forEach(audioElm => {
|
||||||
|
audioElm.play().then(() => {
|
||||||
|
setAudioAutoplayBlocked(false);
|
||||||
|
}).catch(() => undefined);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { useSettingsStore } from "@/hooks/stores";
|
||||||
|
import { GridCard } from "@components/Card";
|
||||||
|
import { SettingsItem } from "@components/SettingsItem";
|
||||||
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
|
import Checkbox from "@components/Checkbox";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
import { m } from "@localizations/messages.js";
|
||||||
|
import { isSecureContext } from "@/utils";
|
||||||
|
|
||||||
|
export default function AudioPopover() {
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
const { microphoneEnabled, setMicrophoneEnabled } = useSettingsStore();
|
||||||
|
const [audioOutputEnabled, setAudioOutputEnabled] = useState<boolean>(true);
|
||||||
|
const [usbAudioEnabled, setUsbAudioEnabled] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [micLoading, setMicLoading] = useState(false);
|
||||||
|
const isHttps = isSecureContext();
|
||||||
|
|
||||||
|
// Helper function to handle RPC errors consistently
|
||||||
|
const handleRpcError = (resp: JsonRpcResponse, errorMsg?: string): boolean => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(errorMsg || String(resp.error.data || m.unknown_error()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
console.error("Failed to load audio output enabled:", resp.error);
|
||||||
|
} else {
|
||||||
|
setAudioOutputEnabled(resp.result as boolean);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
send("getUsbDevices", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
console.error("Failed to load USB devices:", resp.error);
|
||||||
|
} else {
|
||||||
|
const usbDevices = resp.result as { audio: boolean };
|
||||||
|
setUsbAudioEnabled(usbDevices.audio || false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const handleAudioOutputEnabledToggle = useCallback((enabled: boolean) => {
|
||||||
|
setLoading(true);
|
||||||
|
send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => {
|
||||||
|
setLoading(false);
|
||||||
|
if ("error" in resp) {
|
||||||
|
const errorMsg = enabled
|
||||||
|
? m.audio_output_failed_enable({ error: String(resp.error.data || m.unknown_error()) })
|
||||||
|
: m.audio_output_failed_disable({ error: String(resp.error.data || m.unknown_error()) });
|
||||||
|
handleRpcError(resp, errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAudioOutputEnabled(enabled);
|
||||||
|
const successMsg = enabled ? m.audio_output_enabled() : m.audio_output_disabled();
|
||||||
|
notifications.success(successMsg);
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const handleMicrophoneToggle = useCallback((enabled: boolean) => {
|
||||||
|
setMicLoading(true);
|
||||||
|
send("setAudioInputEnabled", { enabled }, (resp: JsonRpcResponse) => {
|
||||||
|
setMicLoading(false);
|
||||||
|
if ("error" in resp) {
|
||||||
|
const errorMsg = enabled
|
||||||
|
? m.audio_input_failed_enable({ error: String(resp.error.data || m.unknown_error()) })
|
||||||
|
: m.audio_input_failed_disable({ error: String(resp.error.data || m.unknown_error()) });
|
||||||
|
handleRpcError(resp, errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMicrophoneEnabled(enabled);
|
||||||
|
});
|
||||||
|
}, [send, setMicrophoneEnabled]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridCard>
|
||||||
|
<div className="space-y-4 p-4 py-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsPageHeader
|
||||||
|
title={m.audio_popover_title()}
|
||||||
|
description={m.audio_popover_description()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SettingsItem
|
||||||
|
loading={loading}
|
||||||
|
title={m.audio_speakers_title()}
|
||||||
|
description={m.audio_speakers_description()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={audioOutputEnabled}
|
||||||
|
onChange={(e) => handleAudioOutputEnabledToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
{usbAudioEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
loading={micLoading}
|
||||||
|
title={m.audio_microphone_title()}
|
||||||
|
description={m.audio_microphone_description()}
|
||||||
|
badge={!isHttps ? m.audio_https_only() : undefined}
|
||||||
|
badgeVariant="info"
|
||||||
|
badgeLink={!isHttps ? "settings/access" : undefined}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={microphoneEnabled}
|
||||||
|
disabled={!isHttps}
|
||||||
|
onChange={(e) => handleMicrophoneToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GridCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -140,6 +140,9 @@ export interface RTCState {
|
||||||
transceiver: RTCRtpTransceiver | null;
|
transceiver: RTCRtpTransceiver | null;
|
||||||
setTransceiver: (transceiver: RTCRtpTransceiver) => void;
|
setTransceiver: (transceiver: RTCRtpTransceiver) => void;
|
||||||
|
|
||||||
|
audioTransceiver: RTCRtpTransceiver | null;
|
||||||
|
setAudioTransceiver: (transceiver: RTCRtpTransceiver) => void;
|
||||||
|
|
||||||
mediaStream: MediaStream | null;
|
mediaStream: MediaStream | null;
|
||||||
setMediaStream: (stream: MediaStream) => void;
|
setMediaStream: (stream: MediaStream) => void;
|
||||||
|
|
||||||
|
|
@ -199,6 +202,9 @@ export const useRTCStore = create<RTCState>(set => ({
|
||||||
transceiver: null,
|
transceiver: null,
|
||||||
setTransceiver: transceiver => set({ transceiver }),
|
setTransceiver: transceiver => set({ transceiver }),
|
||||||
|
|
||||||
|
audioTransceiver: null,
|
||||||
|
setAudioTransceiver: (transceiver: RTCRtpTransceiver) => set({ audioTransceiver: transceiver }),
|
||||||
|
|
||||||
peerConnectionState: null,
|
peerConnectionState: null,
|
||||||
setPeerConnectionState: state => set({ peerConnectionState: state }),
|
setPeerConnectionState: state => set({ peerConnectionState: state }),
|
||||||
|
|
||||||
|
|
@ -375,6 +381,34 @@ export interface SettingsState {
|
||||||
|
|
||||||
videoContrast: number;
|
videoContrast: number;
|
||||||
setVideoContrast: (value: number) => void;
|
setVideoContrast: (value: number) => void;
|
||||||
|
|
||||||
|
// Audio settings
|
||||||
|
audioOutputEnabled: boolean;
|
||||||
|
setAudioOutputEnabled: (enabled: boolean) => void;
|
||||||
|
audioOutputSource: string;
|
||||||
|
setAudioOutputSource: (source: string) => void;
|
||||||
|
microphoneEnabled: boolean;
|
||||||
|
setMicrophoneEnabled: (enabled: boolean) => void;
|
||||||
|
audioInputAutoEnable: boolean;
|
||||||
|
setAudioInputAutoEnable: (enabled: boolean) => void;
|
||||||
|
|
||||||
|
// Audio codec settings
|
||||||
|
audioBitrate: number;
|
||||||
|
setAudioBitrate: (value: number) => void;
|
||||||
|
audioComplexity: number;
|
||||||
|
setAudioComplexity: (value: number) => void;
|
||||||
|
audioDTXEnabled: boolean;
|
||||||
|
setAudioDTXEnabled: (enabled: boolean) => void;
|
||||||
|
audioFECEnabled: boolean;
|
||||||
|
setAudioFECEnabled: (enabled: boolean) => void;
|
||||||
|
audioBufferPeriods: number;
|
||||||
|
setAudioBufferPeriods: (value: number) => void;
|
||||||
|
audioSampleRate: number;
|
||||||
|
setAudioSampleRate: (value: number) => void;
|
||||||
|
audioPacketLossPerc: number;
|
||||||
|
setAudioPacketLossPerc: (value: number) => void;
|
||||||
|
|
||||||
|
resetMicrophoneState: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = create(
|
export const useSettingsStore = create(
|
||||||
|
|
@ -422,6 +456,32 @@ export const useSettingsStore = create(
|
||||||
|
|
||||||
videoContrast: 1.0,
|
videoContrast: 1.0,
|
||||||
setVideoContrast: (value: number) => set({ videoContrast: value }),
|
setVideoContrast: (value: number) => set({ videoContrast: value }),
|
||||||
|
|
||||||
|
audioOutputEnabled: true,
|
||||||
|
setAudioOutputEnabled: (enabled: boolean) => set({ audioOutputEnabled: enabled }),
|
||||||
|
audioOutputSource: "usb",
|
||||||
|
setAudioOutputSource: (source: string) => set({ audioOutputSource: source }),
|
||||||
|
microphoneEnabled: false,
|
||||||
|
setMicrophoneEnabled: (enabled: boolean) => set({ microphoneEnabled: enabled }),
|
||||||
|
audioInputAutoEnable: false,
|
||||||
|
setAudioInputAutoEnable: (enabled: boolean) => set({ audioInputAutoEnable: enabled }),
|
||||||
|
|
||||||
|
audioBitrate: 128,
|
||||||
|
setAudioBitrate: (value: number) => set({ audioBitrate: value }),
|
||||||
|
audioComplexity: 5,
|
||||||
|
setAudioComplexity: (value: number) => set({ audioComplexity: value }),
|
||||||
|
audioDTXEnabled: true,
|
||||||
|
setAudioDTXEnabled: (enabled: boolean) => set({ audioDTXEnabled: enabled }),
|
||||||
|
audioFECEnabled: true,
|
||||||
|
setAudioFECEnabled: (enabled: boolean) => set({ audioFECEnabled: enabled }),
|
||||||
|
audioBufferPeriods: 12,
|
||||||
|
setAudioBufferPeriods: (value: number) => set({ audioBufferPeriods: value }),
|
||||||
|
audioSampleRate: 48000,
|
||||||
|
setAudioSampleRate: (value: number) => set({ audioSampleRate: value }),
|
||||||
|
audioPacketLossPerc: 20,
|
||||||
|
setAudioPacketLossPerc: (value: number) => set({ audioPacketLossPerc: value }),
|
||||||
|
|
||||||
|
resetMicrophoneState: () => set({ microphoneEnabled: false }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "settings",
|
name: "settings",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ const blockedMethodsByReason: Record<string, string[]> = {
|
||||||
video: [
|
video: [
|
||||||
'setStreamQualityFactor',
|
'setStreamQualityFactor',
|
||||||
'getEDID',
|
'getEDID',
|
||||||
|
'getDefaultEDID',
|
||||||
'setEDID',
|
'setEDID',
|
||||||
'getVideoLogStatus',
|
'getVideoLogStatus',
|
||||||
'setDisplayRotation',
|
'setDisplayRotation',
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ const SettingsKeyboardRoute = lazy(() => import("@routes/devices.$id.settings.ke
|
||||||
const SettingsAdvancedRoute = lazy(() => import("@routes/devices.$id.settings.advanced"));
|
const SettingsAdvancedRoute = lazy(() => import("@routes/devices.$id.settings.advanced"));
|
||||||
const SettingsHardwareRoute = lazy(() => import("@routes/devices.$id.settings.hardware"));
|
const SettingsHardwareRoute = lazy(() => import("@routes/devices.$id.settings.hardware"));
|
||||||
const SettingsVideoRoute = lazy(() => import("@routes/devices.$id.settings.video"));
|
const SettingsVideoRoute = lazy(() => import("@routes/devices.$id.settings.video"));
|
||||||
|
const SettingsAudioRoute = lazy(() => import("@routes/devices.$id.settings.audio"));
|
||||||
const SettingsAppearanceRoute = lazy(() => import("@routes/devices.$id.settings.appearance"));
|
const SettingsAppearanceRoute = lazy(() => import("@routes/devices.$id.settings.appearance"));
|
||||||
const SettingsGeneralIndexRoute = lazy(() => import("@routes/devices.$id.settings.general._index"));
|
const SettingsGeneralIndexRoute = lazy(() => import("@routes/devices.$id.settings.general._index"));
|
||||||
const SettingsGeneralRebootRoute = lazy(() => import("@routes/devices.$id.settings.general.reboot"));
|
const SettingsGeneralRebootRoute = lazy(() => import("@routes/devices.$id.settings.general.reboot"));
|
||||||
|
|
@ -191,6 +192,10 @@ if (isOnDevice) {
|
||||||
path: "video",
|
path: "video",
|
||||||
element: <SettingsVideoRoute />,
|
element: <SettingsVideoRoute />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "audio",
|
||||||
|
element: <SettingsAudioRoute />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "appearance",
|
path: "appearance",
|
||||||
element: <SettingsAppearanceRoute />,
|
element: <SettingsAppearanceRoute />,
|
||||||
|
|
@ -324,6 +329,10 @@ if (isOnDevice) {
|
||||||
path: "video",
|
path: "video",
|
||||||
element: <SettingsVideoRoute />,
|
element: <SettingsVideoRoute />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "audio",
|
||||||
|
element: <SettingsAudioRoute />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "appearance",
|
path: "appearance",
|
||||||
element: <SettingsAppearanceRoute />,
|
element: <SettingsAppearanceRoute />,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,311 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { SettingsItem } from "@components/SettingsItem";
|
||||||
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
|
import { useSettingsStore } from "@/hooks/stores";
|
||||||
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
|
import Checkbox from "@components/Checkbox";
|
||||||
|
import { m } from "@localizations/messages.js";
|
||||||
|
|
||||||
|
import notifications from "../notifications";
|
||||||
|
|
||||||
|
interface AudioConfigResult {
|
||||||
|
bitrate: number;
|
||||||
|
complexity: number;
|
||||||
|
dtx_enabled: boolean;
|
||||||
|
fec_enabled: boolean;
|
||||||
|
buffer_periods: number;
|
||||||
|
packet_loss_perc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUDIO_DEFAULTS = {
|
||||||
|
bitrate: 192,
|
||||||
|
complexity: 8,
|
||||||
|
packetLossPerc: 20,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function SettingsAudioRoute() {
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
|
const handleRpcError = (resp: JsonRpcResponse, defaultMsg?: string) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
notifications.error(String(resp.error.data || defaultMsg || m.unknown_error()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
setAudioOutputEnabled,
|
||||||
|
setAudioInputAutoEnable,
|
||||||
|
setAudioOutputSource,
|
||||||
|
audioOutputEnabled,
|
||||||
|
audioInputAutoEnable,
|
||||||
|
audioOutputSource,
|
||||||
|
audioBitrate,
|
||||||
|
setAudioBitrate,
|
||||||
|
audioComplexity,
|
||||||
|
setAudioComplexity,
|
||||||
|
audioDTXEnabled,
|
||||||
|
setAudioDTXEnabled,
|
||||||
|
audioFECEnabled,
|
||||||
|
setAudioFECEnabled,
|
||||||
|
audioBufferPeriods,
|
||||||
|
setAudioBufferPeriods,
|
||||||
|
audioPacketLossPerc,
|
||||||
|
setAudioPacketLossPerc,
|
||||||
|
} = useSettingsStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
setAudioOutputEnabled(resp.result as boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
send("getAudioInputAutoEnable", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
setAudioInputAutoEnable(resp.result as boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
send("getAudioOutputSource", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
setAudioOutputSource(resp.result as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
send("getAudioConfig", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
const config = resp.result as AudioConfigResult;
|
||||||
|
setAudioBitrate(config.bitrate);
|
||||||
|
setAudioComplexity(config.complexity);
|
||||||
|
setAudioDTXEnabled(config.dtx_enabled);
|
||||||
|
setAudioFECEnabled(config.fec_enabled);
|
||||||
|
setAudioBufferPeriods(config.buffer_periods);
|
||||||
|
setAudioPacketLossPerc(config.packet_loss_perc);
|
||||||
|
});
|
||||||
|
}, [send, setAudioOutputEnabled, setAudioInputAutoEnable, setAudioOutputSource, setAudioBitrate, setAudioComplexity, setAudioDTXEnabled, setAudioFECEnabled, setAudioBufferPeriods, setAudioPacketLossPerc]);
|
||||||
|
|
||||||
|
const handleAudioOutputEnabledChange = (enabled: boolean) => {
|
||||||
|
send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
const errorMsg = enabled
|
||||||
|
? m.audio_output_failed_enable({ error: String(resp.error.data || m.unknown_error()) })
|
||||||
|
: m.audio_output_failed_disable({ error: String(resp.error.data || m.unknown_error()) });
|
||||||
|
notifications.error(errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAudioOutputEnabled(enabled);
|
||||||
|
const successMsg = enabled ? m.audio_output_enabled() : m.audio_output_disabled();
|
||||||
|
notifications.success(successMsg);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAudioOutputSourceChange = (source: string) => {
|
||||||
|
send("setAudioOutputSource", { source }, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
const errorMsg = m.audio_settings_output_source_failed({
|
||||||
|
error: String(resp.error.data || m.unknown_error())
|
||||||
|
});
|
||||||
|
notifications.error(errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAudioOutputSource(source);
|
||||||
|
notifications.success(m.audio_settings_output_source_success());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAudioInputAutoEnableChange = (enabled: boolean) => {
|
||||||
|
send("setAudioInputAutoEnable", { enabled }, (resp: JsonRpcResponse) => {
|
||||||
|
if (handleRpcError(resp)) return;
|
||||||
|
|
||||||
|
setAudioInputAutoEnable(enabled);
|
||||||
|
const successMsg = enabled
|
||||||
|
? m.audio_input_auto_enable_enabled()
|
||||||
|
: m.audio_input_auto_enable_disabled();
|
||||||
|
notifications.success(successMsg);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentConfig = () => ({
|
||||||
|
bitrate: audioBitrate,
|
||||||
|
complexity: audioComplexity,
|
||||||
|
dtxEnabled: audioDTXEnabled,
|
||||||
|
fecEnabled: audioFECEnabled,
|
||||||
|
bufferPeriods: audioBufferPeriods,
|
||||||
|
packetLossPerc: audioPacketLossPerc,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAudioConfigChange = (updates: Partial<ReturnType<typeof getCurrentConfig>>) => {
|
||||||
|
const config = { ...getCurrentConfig(), ...updates };
|
||||||
|
|
||||||
|
send("setAudioConfig", config, (resp: JsonRpcResponse) => {
|
||||||
|
if (handleRpcError(resp)) return;
|
||||||
|
|
||||||
|
setAudioBitrate(config.bitrate);
|
||||||
|
setAudioComplexity(config.complexity);
|
||||||
|
setAudioDTXEnabled(config.dtxEnabled);
|
||||||
|
setAudioFECEnabled(config.fecEnabled);
|
||||||
|
setAudioBufferPeriods(config.bufferPeriods);
|
||||||
|
setAudioPacketLossPerc(config.packetLossPerc);
|
||||||
|
notifications.success(m.audio_settings_config_updated());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestartAudio = () => {
|
||||||
|
send("restartAudioOutput", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if (handleRpcError(resp)) return;
|
||||||
|
notifications.success(m.audio_settings_applied());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsPageHeader
|
||||||
|
title={m.audio_settings_title()}
|
||||||
|
description={m.audio_settings_description()}
|
||||||
|
/>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsItem
|
||||||
|
title={m.audio_settings_output_title()}
|
||||||
|
description={m.audio_settings_output_description()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={audioOutputEnabled || false}
|
||||||
|
onChange={(e) => handleAudioOutputEnabledChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={m.audio_settings_output_source_title()}
|
||||||
|
description={m.audio_settings_output_source_description()}
|
||||||
|
>
|
||||||
|
<SelectMenuBasic
|
||||||
|
size="SM"
|
||||||
|
value={audioOutputSource || "usb"}
|
||||||
|
options={[
|
||||||
|
{ value: "usb", label: m.audio_settings_usb_label() },
|
||||||
|
{ value: "hdmi", label: m.audio_settings_hdmi_label() },
|
||||||
|
]}
|
||||||
|
onChange={(e) => handleAudioOutputSourceChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={m.audio_settings_auto_enable_microphone_title()}
|
||||||
|
description={m.audio_settings_auto_enable_microphone_description()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={audioInputAutoEnable || false}
|
||||||
|
onChange={(e) => handleAudioInputAutoEnableChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={m.audio_settings_bitrate_title()}
|
||||||
|
description={m.audio_settings_bitrate_description()}
|
||||||
|
>
|
||||||
|
<SelectMenuBasic
|
||||||
|
size="SM"
|
||||||
|
value={String(audioBitrate)}
|
||||||
|
options={[
|
||||||
|
{ value: "64", label: "64 kbps" },
|
||||||
|
{ value: "96", label: "96 kbps" },
|
||||||
|
{ value: "128", label: "128 kbps" },
|
||||||
|
{ value: "160", label: "160 kbps" },
|
||||||
|
{ value: "192", label: `192 kbps${192 === AUDIO_DEFAULTS.bitrate ? m.audio_settings_default_suffix() : ''}` },
|
||||||
|
{ value: "256", label: "256 kbps" },
|
||||||
|
]}
|
||||||
|
onChange={(e) => handleAudioConfigChange({ bitrate: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={m.audio_settings_complexity_title()}
|
||||||
|
description={m.audio_settings_complexity_description()}
|
||||||
|
>
|
||||||
|
<SelectMenuBasic
|
||||||
|
size="SM"
|
||||||
|
value={String(audioComplexity)}
|
||||||
|
options={[
|
||||||
|
{ value: "0", label: "0 (fastest)" },
|
||||||
|
{ value: "2", label: "2" },
|
||||||
|
{ value: "5", label: "5" },
|
||||||
|
{ value: "8", label: `8${8 === AUDIO_DEFAULTS.complexity ? m.audio_settings_default_suffix() : ''}` },
|
||||||
|
{ value: "10", label: "10 (best quality)" },
|
||||||
|
]}
|
||||||
|
onChange={(e) => handleAudioConfigChange({ complexity: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={m.audio_settings_dtx_title()}
|
||||||
|
description={m.audio_settings_dtx_description()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={audioDTXEnabled}
|
||||||
|
onChange={(e) => handleAudioConfigChange({ dtxEnabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={m.audio_settings_fec_title()}
|
||||||
|
description={m.audio_settings_fec_description()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={audioFECEnabled}
|
||||||
|
onChange={(e) => handleAudioConfigChange({ fecEnabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={m.audio_settings_buffer_title()}
|
||||||
|
description={m.audio_settings_buffer_description()}
|
||||||
|
>
|
||||||
|
<SelectMenuBasic
|
||||||
|
size="SM"
|
||||||
|
value={String(audioBufferPeriods)}
|
||||||
|
options={[
|
||||||
|
{ value: "4", label: "4 (80ms)" },
|
||||||
|
{ value: "8", label: "8 (160ms)" },
|
||||||
|
{ value: "12", label: "12 (240ms)" },
|
||||||
|
{ value: "16", label: "16 (320ms)" },
|
||||||
|
{ value: "24", label: "24 (480ms)" },
|
||||||
|
]}
|
||||||
|
onChange={(e) => handleAudioConfigChange({ bufferPeriods: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={m.audio_settings_packet_loss_title()}
|
||||||
|
description={m.audio_settings_packet_loss_description()}
|
||||||
|
>
|
||||||
|
<SelectMenuBasic
|
||||||
|
size="SM"
|
||||||
|
value={String(audioPacketLossPerc)}
|
||||||
|
options={[
|
||||||
|
{ value: "0", label: `0%${m.audio_settings_no_compensation_suffix()}` },
|
||||||
|
{ value: "5", label: "5%" },
|
||||||
|
{ value: "10", label: "10%" },
|
||||||
|
{ value: "15", label: "15%" },
|
||||||
|
{ value: "20", label: `20%${20 === AUDIO_DEFAULTS.packetLossPerc ? m.audio_settings_default_suffix() : ''}` },
|
||||||
|
{ value: "25", label: "25%" },
|
||||||
|
{ value: "30", label: "30%" },
|
||||||
|
]}
|
||||||
|
onChange={(e) => handleAudioConfigChange({ packetLossPerc: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleRestartAudio}
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{m.audio_settings_apply_button()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -85,7 +85,7 @@ export default function SettingsGeneralRoute() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
badge="Beta"
|
badge="Beta"
|
||||||
badgeTheme="info"
|
badgeVariant="info"
|
||||||
title={m.user_interface_language_title()}
|
title={m.user_interface_language_title()}
|
||||||
description={m.user_interface_language_description()}
|
description={m.user_interface_language_description()}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
LuMouse,
|
LuMouse,
|
||||||
LuKeyboard,
|
LuKeyboard,
|
||||||
LuVideo,
|
LuVideo,
|
||||||
|
LuVolume2,
|
||||||
LuCpu,
|
LuCpu,
|
||||||
LuShieldCheck,
|
LuShieldCheck,
|
||||||
LuWrench,
|
LuWrench,
|
||||||
|
|
@ -178,6 +179,17 @@ export default function SettingsRoute() {
|
||||||
<div className={cx("shrink-0", {
|
<div className={cx("shrink-0", {
|
||||||
"opacity-50 cursor-not-allowed": isVideoDisabled
|
"opacity-50 cursor-not-allowed": isVideoDisabled
|
||||||
})}>
|
})}>
|
||||||
|
<NavLink
|
||||||
|
to="audio"
|
||||||
|
className={({ isActive }) => (isActive ? "active" : "")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
|
||||||
|
<LuVolume2 className="h-4 w-4 shrink-0" />
|
||||||
|
<h1>Audio</h1>
|
||||||
|
</div>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
<NavLink
|
<NavLink
|
||||||
to="hardware"
|
to="hardware"
|
||||||
className={({ isActive }) => cx(isActive ? "active" : "", {
|
className={({ isActive }) => cx(isActive ? "active" : "", {
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,7 @@ import Fieldset from "@components/Fieldset";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { m } from "@localizations/messages.js";
|
import { m } from "@localizations/messages.js";
|
||||||
|
|
||||||
const defaultEdid =
|
const otherEdids = [
|
||||||
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
|
|
||||||
const edids = [
|
|
||||||
{
|
|
||||||
value: defaultEdid,
|
|
||||||
label: m.video_edid_jetkvm_default(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value:
|
value:
|
||||||
"00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096",
|
"00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096",
|
||||||
|
|
@ -53,6 +47,8 @@ export default function SettingsVideoRoute() {
|
||||||
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
||||||
const [edid, setEdid] = useState<string | null>(null);
|
const [edid, setEdid] = useState<string | null>(null);
|
||||||
const [edidLoading, setEdidLoading] = useState(true);
|
const [edidLoading, setEdidLoading] = useState(true);
|
||||||
|
const [defaultEdid, setDefaultEdid] = useState<string>("");
|
||||||
|
const [edids, setEdids] = useState<{value: string, label: string}[]>([]);
|
||||||
const { debugMode } = useSettingsStore();
|
const { debugMode } = useSettingsStore();
|
||||||
// Video enhancement settings from store
|
// Video enhancement settings from store
|
||||||
const {
|
const {
|
||||||
|
|
@ -70,28 +66,42 @@ export default function SettingsVideoRoute() {
|
||||||
setStreamQuality(String(resp.result));
|
setStreamQuality(String(resp.result));
|
||||||
});
|
});
|
||||||
|
|
||||||
send("getEDID", {}, (resp: JsonRpcResponse) => {
|
send("getDefaultEDID", {}, (resp: JsonRpcResponse) => {
|
||||||
setEdidLoading(false);
|
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(m.video_failed_get_edid({ error: resp.error.data || m.unknown_error() }));
|
notifications.error(m.video_failed_get_edid({ error: resp.error.data || m.unknown_error() }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const receivedEdid = resp.result as string;
|
const fetchedDefaultEdid = resp.result as string;
|
||||||
|
setDefaultEdid(fetchedDefaultEdid);
|
||||||
|
|
||||||
const matchingEdid = edids.find(
|
const allEdids = [
|
||||||
x => x.value.toLowerCase() === receivedEdid.toLowerCase(),
|
{ value: fetchedDefaultEdid, label: m.video_edid_jetkvm_default() },
|
||||||
);
|
...otherEdids
|
||||||
|
];
|
||||||
|
setEdids(allEdids);
|
||||||
|
|
||||||
if (matchingEdid) {
|
send("getEDID", {}, (resp: JsonRpcResponse) => {
|
||||||
// EDID is stored in uppercase in the UI
|
setEdidLoading(false);
|
||||||
setEdid(matchingEdid.value.toUpperCase());
|
if ("error" in resp) {
|
||||||
// Reset custom EDID value
|
notifications.error(m.video_failed_get_edid({ error: resp.error.data || m.unknown_error() }));
|
||||||
setCustomEdidValue(null);
|
return;
|
||||||
} else {
|
}
|
||||||
setEdid("custom");
|
|
||||||
setCustomEdidValue(receivedEdid);
|
const receivedEdid = resp.result as string;
|
||||||
}
|
|
||||||
|
const matchingEdid = allEdids.find(
|
||||||
|
x => x.value.toLowerCase() === receivedEdid.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingEdid) {
|
||||||
|
setEdid(matchingEdid.value.toUpperCase());
|
||||||
|
setCustomEdidValue(null);
|
||||||
|
} else {
|
||||||
|
setEdid("custom");
|
||||||
|
setCustomEdidValue(receivedEdid);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||||
import useWebSocket from "react-use-websocket";
|
import useWebSocket from "react-use-websocket";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { CLOUD_API } from "@/ui.config";
|
import { CLOUD_API, OPUS_STEREO_PARAMS } from "@/ui.config";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
||||||
import {
|
import {
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
useNetworkStateStore,
|
useNetworkStateStore,
|
||||||
User,
|
User,
|
||||||
useRTCStore,
|
useRTCStore,
|
||||||
|
useSettingsStore,
|
||||||
useUiStore,
|
useUiStore,
|
||||||
useUpdateStore,
|
useUpdateStore,
|
||||||
useVideoStore,
|
useVideoStore,
|
||||||
|
|
@ -53,6 +54,7 @@ import {
|
||||||
} from "@components/VideoOverlay";
|
} from "@components/VideoOverlay";
|
||||||
import { FeatureFlagProvider } from "@providers/FeatureFlagProvider";
|
import { FeatureFlagProvider } from "@providers/FeatureFlagProvider";
|
||||||
import { m } from "@localizations/messages.js";
|
import { m } from "@localizations/messages.js";
|
||||||
|
import { isSecureContext } from "@/utils";
|
||||||
import { doRpcHidHandshake } from "@hooks/useHidRpc";
|
import { doRpcHidHandshake } from "@hooks/useHidRpc";
|
||||||
|
|
||||||
export type AuthMode = "password" | "noPassword" | null;
|
export type AuthMode = "password" | "noPassword" | null;
|
||||||
|
|
@ -115,6 +117,7 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
const params = useParams() as { id: string };
|
const params = useParams() as { id: string };
|
||||||
const { sidebarView, setSidebarView, disableVideoFocusTrap, rebootState, setRebootState } = useUiStore();
|
const { sidebarView, setSidebarView, disableVideoFocusTrap, rebootState, setRebootState } = useUiStore();
|
||||||
|
const { microphoneEnabled, setMicrophoneEnabled, audioInputAutoEnable, setAudioInputAutoEnable } = useSettingsStore();
|
||||||
const [queryParams, setQueryParams] = useSearchParams();
|
const [queryParams, setQueryParams] = useSearchParams();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -125,6 +128,8 @@ export default function KvmIdRoute() {
|
||||||
isTurnServerInUse, setTurnServerInUse,
|
isTurnServerInUse, setTurnServerInUse,
|
||||||
rpcDataChannel,
|
rpcDataChannel,
|
||||||
setTransceiver,
|
setTransceiver,
|
||||||
|
setAudioTransceiver,
|
||||||
|
audioTransceiver,
|
||||||
setRpcHidChannel,
|
setRpcHidChannel,
|
||||||
setRpcHidUnreliableNonOrderedChannel,
|
setRpcHidUnreliableNonOrderedChannel,
|
||||||
setRpcHidUnreliableChannel,
|
setRpcHidUnreliableChannel,
|
||||||
|
|
@ -177,6 +182,30 @@ export default function KvmIdRoute() {
|
||||||
) {
|
) {
|
||||||
setLoadingMessage(m.setting_remote_description());
|
setLoadingMessage(m.setting_remote_description());
|
||||||
|
|
||||||
|
// Enable stereo in remote answer SDP
|
||||||
|
if (remoteDescription.sdp) {
|
||||||
|
const opusMatch = remoteDescription.sdp.match(/a=rtpmap:(\d+)\s+opus\/48000\/2/i);
|
||||||
|
if (!opusMatch) {
|
||||||
|
console.warn("[SDP] Opus 48kHz stereo not found in answer - stereo may not work");
|
||||||
|
} else {
|
||||||
|
const pt = opusMatch[1];
|
||||||
|
const fmtpRegex = new RegExp(`a=fmtp:${pt}\\s+(.+)`, 'i');
|
||||||
|
const fmtpMatch = remoteDescription.sdp.match(fmtpRegex);
|
||||||
|
|
||||||
|
if (fmtpMatch && !fmtpMatch[1].includes('stereo=')) {
|
||||||
|
remoteDescription.sdp = remoteDescription.sdp.replace(
|
||||||
|
fmtpRegex,
|
||||||
|
`a=fmtp:${pt} ${fmtpMatch[1]};${OPUS_STEREO_PARAMS}`
|
||||||
|
);
|
||||||
|
} else if (!fmtpMatch) {
|
||||||
|
remoteDescription.sdp = remoteDescription.sdp.replace(
|
||||||
|
opusMatch[0],
|
||||||
|
`${opusMatch[0]}\r\na=fmtp:${pt} ${OPUS_STEREO_PARAMS}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
|
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
|
||||||
console.log("[setRemoteSessionDescription] Remote description set successfully to: " + remoteDescription.sdp);
|
console.log("[setRemoteSessionDescription] Remote description set successfully to: " + remoteDescription.sdp);
|
||||||
|
|
@ -439,6 +468,29 @@ export default function KvmIdRoute() {
|
||||||
makingOffer.current = true;
|
makingOffer.current = true;
|
||||||
|
|
||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
|
|
||||||
|
// Enable stereo for Opus audio codec
|
||||||
|
if (offer.sdp) {
|
||||||
|
const opusMatch = offer.sdp.match(/a=rtpmap:(\d+)\s+opus\/48000\/2/i);
|
||||||
|
if (!opusMatch) {
|
||||||
|
console.warn("[SDP] Opus 48kHz stereo not found in offer - stereo may not work");
|
||||||
|
} else {
|
||||||
|
const pt = opusMatch[1];
|
||||||
|
const fmtpRegex = new RegExp(`a=fmtp:${pt}\\s+(.+)`, 'i');
|
||||||
|
const fmtpMatch = offer.sdp.match(fmtpRegex);
|
||||||
|
|
||||||
|
if (fmtpMatch) {
|
||||||
|
// Modify existing fmtp line
|
||||||
|
if (!fmtpMatch[1].includes('stereo=')) {
|
||||||
|
offer.sdp = offer.sdp.replace(fmtpRegex, `a=fmtp:${pt} ${fmtpMatch[1]};${OPUS_STEREO_PARAMS}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add new fmtp line after rtpmap
|
||||||
|
offer.sdp = offer.sdp.replace(opusMatch[0], `${opusMatch[0]}\r\na=fmtp:${pt} ${OPUS_STEREO_PARAMS}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await pc.setLocalDescription(offer);
|
await pc.setLocalDescription(offer);
|
||||||
const sd = btoa(JSON.stringify(pc.localDescription));
|
const sd = btoa(JSON.stringify(pc.localDescription));
|
||||||
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
|
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
|
||||||
|
|
@ -481,11 +533,16 @@ export default function KvmIdRoute() {
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.ontrack = function (event) {
|
pc.ontrack = function (event) {
|
||||||
setMediaStream(event.streams[0]);
|
if (event.track.kind === "video") {
|
||||||
|
setMediaStream(event.streams[0]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
|
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
|
||||||
|
|
||||||
|
const audioTrans = pc.addTransceiver("audio", { direction: "sendrecv" });
|
||||||
|
setAudioTransceiver(audioTrans);
|
||||||
|
|
||||||
const rpcDataChannel = pc.createDataChannel("rpc");
|
const rpcDataChannel = pc.createDataChannel("rpc");
|
||||||
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
|
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
|
||||||
rpcDataChannel.onerror = (ev: Event) => console.error(`Error on DataChannel '${rpcDataChannel.label}': ${ev}`);
|
rpcDataChannel.onerror = (ev: Event) => console.error(`Error on DataChannel '${rpcDataChannel.label}': ${ev}`);
|
||||||
|
|
@ -539,6 +596,9 @@ export default function KvmIdRoute() {
|
||||||
setRpcHidUnreliableChannel,
|
setRpcHidUnreliableChannel,
|
||||||
setRpcHidProtocolVersion,
|
setRpcHidProtocolVersion,
|
||||||
setTransceiver,
|
setTransceiver,
|
||||||
|
setAudioTransceiver,
|
||||||
|
audioInputAutoEnable,
|
||||||
|
setMicrophoneEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -548,6 +608,66 @@ export default function KvmIdRoute() {
|
||||||
}
|
}
|
||||||
}, [peerConnectionState, cleanupAndStopReconnecting]);
|
}, [peerConnectionState, cleanupAndStopReconnecting]);
|
||||||
|
|
||||||
|
const microphoneRequestInProgress = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audioTransceiver || !peerConnection) return;
|
||||||
|
|
||||||
|
if (microphoneEnabled) {
|
||||||
|
if (microphoneRequestInProgress.current) return;
|
||||||
|
|
||||||
|
const currentTrack = audioTransceiver.sender.track;
|
||||||
|
if (currentTrack) {
|
||||||
|
currentTrack.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestMicrophone = () => {
|
||||||
|
microphoneRequestInProgress.current = true;
|
||||||
|
navigator.mediaDevices?.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
channelCount: 1,
|
||||||
|
}
|
||||||
|
}).then((stream) => {
|
||||||
|
microphoneRequestInProgress.current = false;
|
||||||
|
const audioTrack = stream.getAudioTracks()[0];
|
||||||
|
if (audioTrack && audioTransceiver.sender) {
|
||||||
|
const handleTrackEnded = () => {
|
||||||
|
console.warn('Microphone track ended unexpectedly, attempting to restart...');
|
||||||
|
if (audioTransceiver.sender.track === audioTrack) {
|
||||||
|
audioTransceiver.sender.replaceTrack(null);
|
||||||
|
setTimeout(requestMicrophone, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
audioTrack.addEventListener('ended', handleTrackEnded, { once: true });
|
||||||
|
audioTransceiver.sender.replaceTrack(audioTrack);
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
microphoneRequestInProgress.current = false;
|
||||||
|
console.error('Failed to get microphone:', err);
|
||||||
|
setMicrophoneEnabled(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
requestMicrophone();
|
||||||
|
} else {
|
||||||
|
microphoneRequestInProgress.current = false;
|
||||||
|
if (audioTransceiver.sender.track) {
|
||||||
|
audioTransceiver.sender.track.stop();
|
||||||
|
audioTransceiver.sender.replaceTrack(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount or when dependencies change
|
||||||
|
return () => {
|
||||||
|
if (audioTransceiver?.sender.track) {
|
||||||
|
audioTransceiver.sender.track.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [microphoneEnabled, audioTransceiver, peerConnection, setMicrophoneEnabled]);
|
||||||
|
|
||||||
// Cleanup effect
|
// Cleanup effect
|
||||||
const { clearInboundRtpStats, clearCandidatePairStats } = useRTCStore();
|
const { clearInboundRtpStats, clearCandidatePairStats } = useRTCStore();
|
||||||
|
|
||||||
|
|
@ -721,6 +841,7 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
const { send } = useJsonRpc(onJsonRpcRequest);
|
const { send } = useJsonRpc(onJsonRpcRequest);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rpcDataChannel?.readyState !== "open") return;
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
console.log("Requesting video state");
|
console.log("Requesting video state");
|
||||||
|
|
@ -732,6 +853,46 @@ export default function KvmIdRoute() {
|
||||||
});
|
});
|
||||||
}, [rpcDataChannel?.readyState, send, setHdmiState]);
|
}, [rpcDataChannel?.readyState, send, setHdmiState]);
|
||||||
|
|
||||||
|
const [audioInputAutoEnableLoaded, setAudioInputAutoEnableLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (rpcDataChannel?.readyState !== "open") return;
|
||||||
|
send("getAudioInputAutoEnable", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
setAudioInputAutoEnable(resp.result as boolean);
|
||||||
|
setAudioInputAutoEnableLoaded(true);
|
||||||
|
});
|
||||||
|
}, [rpcDataChannel?.readyState, send, setAudioInputAutoEnable]);
|
||||||
|
|
||||||
|
const autoEnableAppliedRef = useRef(false);
|
||||||
|
const audioInputAutoEnableValueRef = useRef(audioInputAutoEnable);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
audioInputAutoEnableValueRef.current = audioInputAutoEnable;
|
||||||
|
}, [audioInputAutoEnable]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audioTransceiver || !peerConnection || microphoneEnabled) return;
|
||||||
|
if (!audioInputAutoEnableLoaded || autoEnableAppliedRef.current) return;
|
||||||
|
|
||||||
|
if (audioInputAutoEnableValueRef.current && isSecureContext()) {
|
||||||
|
autoEnableAppliedRef.current = true;
|
||||||
|
send("setAudioInputEnabled", { enabled: true }, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
console.error("Failed to auto-enable audio input:", resp.error);
|
||||||
|
} else {
|
||||||
|
setMicrophoneEnabled(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [audioTransceiver, peerConnection, audioInputAutoEnableLoaded, microphoneEnabled, setMicrophoneEnabled, send]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!peerConnection) {
|
||||||
|
autoEnableAppliedRef.current = false;
|
||||||
|
setAudioInputAutoEnableLoaded(false);
|
||||||
|
}
|
||||||
|
}, [peerConnection]);
|
||||||
|
|
||||||
const [needLedState, setNeedLedState] = useState(true);
|
const [needLedState, setNeedLedState] = useState(true);
|
||||||
|
|
||||||
// request keyboard led state from the device
|
// request keyboard led state from the device
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,11 @@ import { DeviceStatus } from "@routes/welcome-local";
|
||||||
import { DEVICE_API } from "@/ui.config";
|
import { DEVICE_API } from "@/ui.config";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
import { m } from "@localizations/messages.js";
|
import { m } from "@localizations/messages.js";
|
||||||
|
import { useSettingsStore } from "@/hooks/stores";
|
||||||
|
|
||||||
const loader: LoaderFunction = async () => {
|
const loader: LoaderFunction = async () => {
|
||||||
|
useSettingsStore.getState().resetMicrophoneState();
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.GET(`${DEVICE_API}/device/status`)
|
.GET(`${DEVICE_API}/device/status`)
|
||||||
.then(res => res.json() as Promise<DeviceStatus>);
|
.then(res => res.json() as Promise<DeviceStatus>);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useLocation, useSearchParams } from "react-router";
|
import { useLocation, useSearchParams } from "react-router";
|
||||||
|
|
||||||
import { m } from "@localizations/messages.js";
|
import { m } from "@localizations/messages.js";
|
||||||
import AuthLayout from "@components/AuthLayout";
|
import AuthLayout from "@components/AuthLayout";
|
||||||
|
import { useSettingsStore } from "@/hooks/stores";
|
||||||
|
|
||||||
export default function LoginRoute() {
|
export default function LoginRoute() {
|
||||||
const [sq] = useSearchParams();
|
const [sq] = useSearchParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const deviceId = sq.get("deviceId") || location.state?.deviceId;
|
const deviceId = sq.get("deviceId") || location.state?.deviceId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
useSettingsStore.getState().resetMicrophoneState();
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (deviceId) {
|
if (deviceId) {
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,6 @@ export const DOWNGRADE_VERSION = import.meta.env.VITE_DOWNGRADE_VERSION || "0.4.
|
||||||
|
|
||||||
// In device mode, an empty string uses the current hostname (the JetKVM device's IP) as the API endpoint
|
// In device mode, an empty string uses the current hostname (the JetKVM device's IP) as the API endpoint
|
||||||
export const DEVICE_API = "";
|
export const DEVICE_API = "";
|
||||||
|
|
||||||
|
// Opus codec parameters for stereo audio with error correction
|
||||||
|
export const OPUS_STEREO_PARAMS = 'stereo=1;sprop-stereo=1;maxaveragebitrate=128000;usedtx=1;useinbandfec=1';
|
||||||
|
|
|
||||||
|
|
@ -301,3 +301,7 @@ export function deleteCookie(name: string, domain?: string, path = "/") {
|
||||||
export function sleep(ms: number): Promise<void> {
|
export function sleep(ms: number): Promise<void> {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSecureContext(): boolean {
|
||||||
|
return window.location.protocol === "https:" || window.location.hostname === "localhost";
|
||||||
|
}
|
||||||
|
|
|
||||||
19
usb.go
19
usb.go
|
|
@ -9,7 +9,6 @@ import (
|
||||||
|
|
||||||
var gadget *usbgadget.UsbGadget
|
var gadget *usbgadget.UsbGadget
|
||||||
|
|
||||||
// initUsbGadget initializes the USB gadget.
|
|
||||||
// call it only after the config is loaded.
|
// call it only after the config is loaded.
|
||||||
func initUsbGadget() {
|
func initUsbGadget() {
|
||||||
gadget = usbgadget.NewUsbGadget(
|
gadget = usbgadget.NewUsbGadget(
|
||||||
|
|
@ -44,7 +43,6 @@ func initUsbGadget() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// open the keyboard hid file to listen for keyboard events
|
|
||||||
if err := gadget.OpenKeyboardHidFile(); err != nil {
|
if err := gadget.OpenKeyboardHidFile(); err != nil {
|
||||||
usbLogger.Error().Err(err).Msg("failed to open keyboard hid file")
|
usbLogger.Error().Err(err).Msg("failed to open keyboard hid file")
|
||||||
}
|
}
|
||||||
|
|
@ -109,8 +107,23 @@ func checkUSBState() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldState := usbState
|
||||||
usbState = newState
|
usbState = newState
|
||||||
usbLogger.Info().Str("from", usbState).Str("to", newState).Msg("USB state changed")
|
usbLogger.Info().Str("from", oldState).Str("to", newState).Msg("USB state changed")
|
||||||
|
|
||||||
|
if oldState == "configured" && newState != "configured" {
|
||||||
|
usbLogger.Info().Msg("USB deconfigured, closing HID files")
|
||||||
|
gadget.CloseHidFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
if newState == "configured" && oldState != "configured" {
|
||||||
|
usbLogger.Info().Msg("USB configured, reopening HID files")
|
||||||
|
gadget.CloseHidFiles()
|
||||||
|
gadget.PreOpenHidFiles()
|
||||||
|
if err := gadget.OpenKeyboardHidFile(); err != nil {
|
||||||
|
usbLogger.Error().Err(err).Msg("failed to reopen keyboard hid file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
requestDisplayUpdate(true, "usb_state_changed")
|
requestDisplayUpdate(true, "usb_state_changed")
|
||||||
triggerUSBStateUpdate()
|
triggerUSBStateUpdate()
|
||||||
|
|
|
||||||
39
webrtc.go
39
webrtc.go
|
|
@ -22,6 +22,7 @@ import (
|
||||||
type Session struct {
|
type Session struct {
|
||||||
peerConnection *webrtc.PeerConnection
|
peerConnection *webrtc.PeerConnection
|
||||||
VideoTrack *webrtc.TrackLocalStaticSample
|
VideoTrack *webrtc.TrackLocalStaticSample
|
||||||
|
AudioTrack *webrtc.TrackLocalStaticSample
|
||||||
ControlChannel *webrtc.DataChannel
|
ControlChannel *webrtc.DataChannel
|
||||||
RPCChannel *webrtc.DataChannel
|
RPCChannel *webrtc.DataChannel
|
||||||
HidChannel *webrtc.DataChannel
|
HidChannel *webrtc.DataChannel
|
||||||
|
|
@ -327,6 +328,40 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
session.AudioTrack, err = webrtc.NewTrackLocalStaticSample(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
|
||||||
|
"audio",
|
||||||
|
"kvm-audio",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("Failed to create AudioTrack (non-fatal)")
|
||||||
|
session.AudioTrack = nil
|
||||||
|
} else {
|
||||||
|
_, err = peerConnection.AddTransceiverFromTrack(session.AudioTrack, webrtc.RTPTransceiverInit{
|
||||||
|
Direction: webrtc.RTPTransceiverDirectionSendrecv,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("Failed to add AudioTrack transceiver (non-fatal)")
|
||||||
|
session.AudioTrack = nil
|
||||||
|
} else {
|
||||||
|
setAudioTrack(session.AudioTrack)
|
||||||
|
|
||||||
|
peerConnection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||||
|
scopedLogger.Info().
|
||||||
|
Str("codec", track.Codec().MimeType).
|
||||||
|
Str("track_id", track.ID()).
|
||||||
|
Msg("Received incoming audio track from browser")
|
||||||
|
|
||||||
|
// Store track for connection when audio starts
|
||||||
|
// OnTrack fires during SDP exchange, before ICE connection completes
|
||||||
|
setPendingInputTrack(track)
|
||||||
|
})
|
||||||
|
|
||||||
|
scopedLogger.Info().Msg("Audio tracks configured successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var isConnected bool
|
var isConnected bool
|
||||||
|
|
||||||
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||||
|
|
@ -357,6 +392,8 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
}
|
}
|
||||||
if connectionState == webrtc.ICEConnectionStateClosed {
|
if connectionState == webrtc.ICEConnectionStateClosed {
|
||||||
scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media")
|
scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media")
|
||||||
|
// Only clear currentSession if this is actually the current session
|
||||||
|
// This prevents race condition where old session closes after new one connects
|
||||||
if session == currentSession {
|
if session == currentSession {
|
||||||
// Cancel any ongoing keyboard report multi when session closes
|
// Cancel any ongoing keyboard report multi when session closes
|
||||||
cancelKeyboardMacro()
|
cancelKeyboardMacro()
|
||||||
|
|
@ -403,10 +440,12 @@ func onActiveSessionsChanged() {
|
||||||
func onFirstSessionConnected() {
|
func onFirstSessionConnected() {
|
||||||
notifyFailsafeMode(currentSession)
|
notifyFailsafeMode(currentSession)
|
||||||
_ = nativeInstance.VideoStart()
|
_ = nativeInstance.VideoStart()
|
||||||
|
onWebRTCConnect()
|
||||||
stopVideoSleepModeTicker()
|
stopVideoSleepModeTicker()
|
||||||
}
|
}
|
||||||
|
|
||||||
func onLastSessionDisconnected() {
|
func onLastSessionDisconnected() {
|
||||||
_ = nativeInstance.VideoStop()
|
_ = nativeInstance.VideoStop()
|
||||||
|
onWebRTCDisconnect()
|
||||||
startVideoSleepModeTicker()
|
startVideoSleepModeTicker()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue