This commit is contained in:
Alex 2025-11-25 08:41:44 +00:00 committed by GitHub
commit a1997ba069
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 4275 additions and 371 deletions

View File

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

115
.github/copilot-instructions.md vendored Normal file
View File

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

10
.gitignore vendored
View File

@ -15,3 +15,13 @@ node_modules
#internal/native/lib
ui/reports
# Log files
*.log
app.log
deploy.log
# Development files
.devcontainer.json
utilities/
core

View File

@ -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.
#### 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
1. **Clone the repository:**

View File

@ -6,6 +6,8 @@ ENV GOPATH=/go
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
COPY install-deps.sh /install-deps.sh
COPY install_audio_deps.sh /install_audio_deps.sh
RUN /install-deps.sh
# Create build directory

View File

@ -47,6 +47,10 @@ BIN_DIR := $(shell pwd)/bin
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:
@if [ "$(SKIP_NATIVE_IF_EXISTS)" = "1" ] && [ -f "internal/native/cgo/lib/libjknative.a" ]; then \
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
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
# 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

466
audio.go Normal file
View File

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

View File

@ -10,6 +10,7 @@ import (
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/native"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/prometheus/client_golang/prometheus"
@ -95,7 +96,7 @@ type Config struct {
IncludePreRelease bool `json:"include_pre_release"`
HashedPassword string `json:"hashed_password"`
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"`
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
@ -113,6 +114,15 @@ type Config struct {
DefaultLogLevel string `json:"default_log_level"`
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
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"`
}
@ -147,8 +157,8 @@ func (c *Config) SetDisplayRotation(rotation string) error {
const configPath = "/userdata/kvm_config.json"
// it's a temporary solution to avoid sharing the same pointer
// we should migrate to a proper config solution in the future
// Default configuration structs used to create independent copies in getDefaultConfig().
// These are package-level variables to avoid repeated allocations.
var (
defaultJigglerConfig = JigglerConfig{
InactivityLimitSeconds: 60,
@ -168,6 +178,7 @@ var (
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
Audio: true,
}
)
@ -181,6 +192,7 @@ func getDefaultConfig() Config {
KeyboardMacros: []KeyboardMacro{},
DisplayRotation: "270",
KeyboardLayout: "en-US",
EdidString: native.DefaultEDID,
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes
@ -197,6 +209,15 @@ func getDefaultConfig() Config {
}(),
DefaultLogLevel: "INFO",
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
}
// 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
if loadedConfig.KeyboardLayout == "en_US" {
loadedConfig.KeyboardLayout = "en-US"

1182
internal/audio/c/audio.c Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

170
internal/audio/relay.go Normal file
View File

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

33
internal/audio/source.go Normal file
View File

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

View File

@ -769,13 +769,12 @@ uint8_t video_get_streaming_status() {
void video_restart_streaming()
{
uint8_t streaming_status = video_get_streaming_status();
if (streaming_status == 0)
{
log_info("will not restart video streaming because it's stopped");
if (streaming_status == 0 && !detected_signal) {
return;
}
if (streaming_status == 2) {
if (streaming_status != 0) {
video_stop_streaming();
}
@ -802,7 +801,6 @@ void *run_detect_format(void *arg)
while (!should_exit)
{
ensure_sleep_mode_disabled();
memset(&dv_timings, 0, sizeof(dv_timings));
if (ioctl(sub_dev_fd, VIDIOC_QUERY_DV_TIMINGS, &dv_timings) != 0)

View File

@ -8,7 +8,6 @@ import (
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"
var extraLockTimeout = 5 * time.Second
@ -153,10 +152,6 @@ func (n *Native) VideoSetEDID(edid string) error {
n.videoLock.Lock()
defer n.videoLock.Unlock()
if edid == "" {
edid = DefaultEDID
}
return n.useExtraLock(func() error {
return videoSetEDID(edid)
})
@ -170,6 +165,11 @@ func (n *Native) VideoGetEDID() (string, error) {
return videoGetEDID()
}
// GetDefaultEDID returns the default EDID constant.
func (n *Native) GetDefaultEDID() string {
return DefaultEDID
}
// VideoLogStatus gets the log status for the video stream.
func (n *Native) VideoLogStatus() (string, error) {
n.videoLock.Lock()

View File

@ -1,6 +1,8 @@
// 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
package tzdata
var TimeZones = []string{
"Africa/Abidjan",
"Africa/Accra",

View File

@ -1,7 +1,9 @@
package usbgadget
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog"
"github.com/sourcegraph/tf-dag/dag"
@ -114,7 +116,20 @@ func (c *ChangeSetResolver) resolveChanges(initial bool) 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 {
select {
case <-ctx.Done():
return fmt.Errorf("USB gadget reconfiguration timed out after %v: %w", timeout, ctx.Err())
default:
}
change.ResetActionResolution()
action := change.Action()
actionStr := FileChangeResolvedActionString[action]
@ -126,7 +141,7 @@ func (c *ChangeSetResolver) applyChanges() error {
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 change.IgnoreErrors {
c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error")
@ -139,6 +154,20 @@ func (c *ChangeSetResolver) applyChanges() error {
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) {
localChanges := c.changeset.Changes
changesMap := make(map[string]*FileChange)

View File

@ -59,6 +59,23 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
// mass storage
"mass_storage_base": massStorageBaseConfig,
"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 {
@ -73,6 +90,8 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
return u.enabledDevices.MassStorage
case "mass_storage_lun0":
return u.enabledDevices.MassStorage
case "audio":
return u.enabledDevices.Audio
default:
return true
}
@ -115,6 +134,13 @@ func (u *UsbGadget) SetGadgetDevices(devices *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.
func (u *UsbGadget) GetConfigPath(itemKey string) (string, error) {
item, ok := u.configMap[itemKey]
@ -182,6 +208,9 @@ func (u *UsbGadget) Init() error {
return u.logError("unable to initialize USB stack", err)
}
// Pre-open HID files to reduce input latency
u.PreOpenHidFiles()
return nil
}
@ -191,11 +220,17 @@ func (u *UsbGadget) UpdateGadgetConfig() error {
u.loadGadgetConfig()
// Close HID files before reconfiguration to prevent "file already closed" errors
u.CloseHidFiles()
err := u.configureUsbGadget(true)
if err != nil {
return u.logError("unable to update gadget config", err)
}
// Reopen HID files after reconfiguration
u.PreOpenHidFiles()
return nil
}

View File

@ -1,10 +1,12 @@
package usbgadget
import (
"context"
"fmt"
"path"
"path/filepath"
"sort"
"time"
"github.com/rs/zerolog"
)
@ -52,22 +54,49 @@ func (u *UsbGadget) newUsbGadgetTransaction(lock bool) error {
}
func (u *UsbGadget) WithTransaction(fn func() error) error {
return u.WithTransactionTimeout(fn, 60*time.Second)
}
// WithTransactionTimeout executes a USB gadget transaction with a specified timeout
// to prevent indefinite blocking during USB reconfiguration operations
func (u *UsbGadget) WithTransactionTimeout(fn func() error, timeout time.Duration) error {
// Create a context with timeout for the entire transaction
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// 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")
return err
done <- err
return
}
if err := fn(); err != nil {
u.log.Error().Err(err).Msg("transaction failed")
return err
done <- err
return
}
result := u.tx.Commit()
u.tx = nil
done <- result
}()
return 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 {

View File

@ -19,6 +19,16 @@ type Devices struct {
RelativeMouse bool `json:"relative_mouse"`
Keyboard bool `json:"keyboard"`
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.
@ -39,6 +49,7 @@ var defaultUsbGadgetDevices = Devices{
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
Audio: true,
}
type KeysDownState struct {
@ -188,3 +199,52 @@ func (u *UsbGadget) Close() error {
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")
}
}

View File

@ -19,6 +19,7 @@ import (
"go.bug.st/serial"
"github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/native"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/jetkvm/kvm/internal/utils"
)
@ -123,7 +124,6 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
Interface("id", request.ID).Logger()
scopedLogger.Trace().Msg("Received RPC request")
t := time.Now()
handler, ok := rpcHandlers[request.Method]
if !ok {
@ -155,7 +155,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
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{
JSONRPC: "2.0",
@ -209,15 +209,16 @@ func rpcSetAutoUpdateState(enabled bool) (bool, error) {
}
func rpcGetEDID() (string, error) {
resp, err := nativeInstance.VideoGetEDID()
if err != nil {
return "", err
}
return resp, nil
return config.EdidString, nil
}
func rpcGetDefaultEDID() (string, error) {
return native.DefaultEDID, nil
}
func rpcSetEDID(edid string) error {
if edid == "" {
edid = native.DefaultEDID
logger.Info().Msg("Restoring EDID to default")
} else {
logger.Info().Str("edid", edid).Msg("Setting EDID")
@ -227,12 +228,26 @@ func rpcSetEDID(edid string) error {
return err
}
// Save EDID to config, allowing it to be restored on reboot.
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
}
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) {
return nativeInstance.VideoLogStatus()
}
@ -436,7 +451,7 @@ type RPCHandler struct {
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) {
// Use defer to recover from a panic
defer func() {
@ -606,6 +621,9 @@ func rpcGetMassStorageMode() (string, error) {
}
func rpcIsUpdatePending() (bool, error) {
if otaState == nil {
return false, nil
}
return otaState.IsUpdatePending(), nil
}
@ -628,9 +646,12 @@ func rpcGetUsbConfig() (usbgadget.Config, error) {
func rpcSetUsbConfig(usbConfig usbgadget.Config) error {
LoadConfig()
wasUsbAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio
config.UsbConfig = &usbConfig
gadget.SetGadgetConfig(config.UsbConfig)
return updateUsbRelatedConfig()
return updateUsbRelatedConfig(wasUsbAudioEnabled)
}
func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) {
@ -842,23 +863,60 @@ func rpcGetUsbDevices() (usbgadget.Devices, error) {
return *config.UsbDevices, nil
}
func updateUsbRelatedConfig() error {
if err := gadget.UpdateGadgetConfig(); err != nil {
return fmt.Errorf("failed to write gadget config: %w", err)
func updateUsbRelatedConfig(wasUsbAudioEnabled bool) error {
ensureConfigLoaded()
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 {
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
}
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
gadget.SetGadgetDevices(config.UsbDevices)
return updateUsbRelatedConfig()
return updateUsbRelatedConfig(wasUsbAudioEnabled)
}
func rpcSetUsbDeviceState(device string, enabled bool) error {
wasUsbAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio
currentDevices := gadget.GetGadgetDevices()
switch device {
case "absoluteMouse":
config.UsbDevices.AbsoluteMouse = enabled
@ -868,11 +926,117 @@ func rpcSetUsbDeviceState(device string, enabled bool) error {
config.UsbDevices.Keyboard = enabled
case "massStorage":
config.UsbDevices.MassStorage = enabled
case "audio":
config.UsbDevices.Audio = enabled
default:
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)
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 {
@ -1148,6 +1312,7 @@ var rpcHandlers = map[string]RPCHandler{
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
"getEDID": {Func: rpcGetEDID},
"getDefaultEDID": {Func: rpcGetDefaultEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getVideoLogStatus": {Func: rpcGetVideoLogStatus},
"getVideoSleepMode": {Func: rpcGetVideoSleepMode},
@ -1156,8 +1321,8 @@ var rpcHandlers = map[string]RPCHandler{
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getLocalVersion": {Func: rpcGetLocalVersion},
"getUpdateStatus": {Func: rpcGetUpdateStatus},
"getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel, Params: []string{"channel"}},
"checkUpdateComponents": {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}},
"getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel},
"tryUpdate": {Func: rpcTryUpdate},
"tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}},
"getDevModeState": {Func: rpcGetDevModeState},
@ -1200,6 +1365,18 @@ var rpcHandlers = map[string]RPCHandler{
"getUsbDevices": {Func: rpcGetUsbDevices},
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
"getAudioOutputEnabled": {Func: rpcGetAudioOutputEnabled},
"setAudioOutputEnabled": {Func: rpcSetAudioOutputEnabled, Params: []string{"enabled"}},
"getAudioInputEnabled": {Func: rpcGetAudioInputEnabled},
"setAudioInputEnabled": {Func: rpcSetAudioInputEnabled, Params: []string{"enabled"}},
"getAudioOutputSource": {Func: rpcGetAudioOutputSource},
"setAudioOutputSource": {Func: rpcSetAudioOutputSource, Params: []string{"source"}},
"refreshHdmiConnection": {Func: rpcRefreshHdmiConnection},
"getAudioConfig": {Func: rpcGetAudioConfig},
"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"}},

View File

@ -58,6 +58,9 @@ func Main() {
initNative(systemVersionLocal, appVersionLocal)
initDisplay()
initAudio()
defer stopAudio()
http.DefaultClient.Timeout = 1 * time.Minute
err = rootcerts.UpdateDefaultTransport()
@ -104,6 +107,7 @@ func Main() {
if err := initImagesFolder(); err != nil {
logger.Warn().Err(err).Msg("failed to init images folder")
}
initJiggler()
// start video sleep mode timer
@ -170,6 +174,7 @@ func Main() {
<-sigs
logger.Log().Msg("JetKVM Shutting Down")
//if fuseServer != nil {
// err := setMassStorageImage(" ")
// if err != nil {

View File

@ -41,6 +41,7 @@ BUILD_IN_DOCKER=${BUILD_IN_DOCKER:-false}
function prepare_docker_build_context() {
msg_info "▶ Preparing docker build context ..."
cp .devcontainer/install-deps.sh \
.devcontainer/install_audio_deps.sh \
go.mod \
go.sum \
Dockerfile.build \

View File

@ -196,6 +196,11 @@ EOF
exit 0
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
# When using `make build_release`, the frontend will be built regardless of the `SKIP_UI_BUILD` flag
# check if static/index.html exists
@ -260,18 +265,20 @@ fi
if [ "$INSTALL_APP" = true ]
then
msg_info "▶ Building release binary"
# Build audio dependencies and release binary
do_make build_audio_deps
do_make build_release \
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
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
# Reboot the device, the new app will be deployed by the startup process.
sshdev "reboot"
else
msg_info "▶ Building development binary"
# Build audio dependencies and development binary
do_make build_audio_deps
do_make build_dev \
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \

View File

@ -47,6 +47,7 @@
"access_tls_self_signed": "Selvsigneret",
"access_tls_updated": "TLS-indstillingerne er blevet opdateret",
"access_update_tls_settings": "Opdater TLS-indstillinger",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Forbindelsesstatistik",
"action_bar_extension": "Udvidelse",
"action_bar_fullscreen": "Fuldskærm",
@ -74,7 +75,6 @@
"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_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_title": "Kun loopback-tilstand",
"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_usb_emulation_description": "Styr USB-emuleringstilstanden",
"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_other_user": "Denne enhed er i øjeblikket registreret til en anden bruger i vores cloud-dashboard.",
"already_adopted_return_to_dashboard": "Tilbage til dashboardet",
@ -134,6 +121,50 @@
"atx_power_control_reset_button": "Nulstil",
"atx_power_control_send_action_error": "Kunne ikke sende ATX-strømfunktion {action} : {error}",
"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_error": "Der opstod en fejl under indstilling af godkendelsestilstanden",
"auth_authentication_mode_invalid": "Ugyldig godkendelsestilstand",
@ -193,7 +224,6 @@
"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_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_description": "Rundrejsetid for det aktive ICE-kandidatpar mellem peers.",
"connection_stats_sidebar": "Forbindelsesstatistik",
@ -259,7 +289,6 @@
"general_auto_update_description": "Opdater automatisk enheden til den nyeste version",
"general_auto_update_error": "Kunne ikke indstille automatisk opdatering: {error}",
"general_auto_update_title": "Automatisk opdatering",
"general_check_for_stable_updates": "Nedgradering",
"general_check_for_updates": "Tjek for opdateringer",
"general_page_description": "Konfigurer enhedsindstillinger og opdater præferencer",
"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_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_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_details": "Fejldetaljer: {errorMessage}",
"general_update_error_title": "Opdateringsfejl",
"general_update_keep_current_button": "Behold den aktuelle version",
"general_update_later_button": "Opdater senere",
"general_update_now_button": "Opdater nu",
"general_update_rebooting": "Genstarter for at fuldføre opdateringen…",
@ -302,7 +327,6 @@
"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_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}",
"hardware_backlight_settings_error": "Kunne ikke indstille baggrundsbelysningsindstillinger: {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_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_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_title": "Aktivér tastatur",
"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_set": "Kunne ikke indstille USB-enheder: {error}",
"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_restore_default": "Gendan til standard",
"usb_device_title": "USB-enhed",

View File

@ -47,6 +47,7 @@
"access_tls_self_signed": "Selbstsigniert",
"access_tls_updated": "TLS-Einstellungen erfolgreich aktualisiert",
"access_update_tls_settings": "TLS-Einstellungen aktualisieren",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Verbindungsstatistiken",
"action_bar_extension": "Erweiterung",
"action_bar_fullscreen": "Vollbild",
@ -74,7 +75,6 @@
"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_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_title": "Nur-Loopback-Modus",
"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_usb_emulation_description": "Steuern des USB-Emulationsstatus",
"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_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",
@ -134,6 +121,50 @@
"atx_power_control_reset_button": "Reset-Taste",
"atx_power_control_send_action_error": "ATX-Stromversorgungsaktion {action} konnte nicht gesendet werden: {error}",
"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_error": "Beim Einstellen des Authentifizierungsmodus ist ein Fehler aufgetreten",
"auth_authentication_mode_invalid": "Ungültiger Authentifizierungsmodus",
@ -193,7 +224,6 @@
"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_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_description": "Roundtrip-Zeit für das aktive ICE-Kandidatenpaar zwischen Peers.",
"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_error": "Automatische Aktualisierung konnte nicht eingestellt werden: {error}",
"general_auto_update_title": "Automatische Aktualisierung",
"general_check_for_stable_updates": "Herabstufung",
"general_check_for_updates": "Nach Updates suchen",
"general_page_description": "Geräteeinstellungen konfigurieren und Voreinstellungen aktualisieren",
"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_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_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_details": "Fehlerdetails: {errorMessage}",
"general_update_error_title": "Aktualisierungsfehler",
"general_update_keep_current_button": "Aktuelle Version beibehalten",
"general_update_later_button": "Später",
"general_update_now_button": "Jetzt aktualisieren",
"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_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_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}",
"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}",
@ -817,6 +841,8 @@
"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_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_title": "Tastatur aktivieren",
"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_set": "Fehler beim Festlegen der USB-Geräte: {error}",
"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_restore_default": "Auf Standard zurücksetzen",
"usb_device_title": "USB-Gerät",

View File

@ -47,6 +47,7 @@
"access_tls_self_signed": "Self-signed",
"access_tls_updated": "TLS settings updated successfully",
"access_update_tls_settings": "Update TLS Settings",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Connection Stats",
"action_bar_extension": "Extension",
"action_bar_fullscreen": "Fullscreen",
@ -134,6 +135,53 @@
"atx_power_control_reset_button": "Reset",
"atx_power_control_send_action_error": "Failed to send ATX power action {action}: {error}",
"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_error": "An error occurred while setting the 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_enable_absolute_mouse_description": "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_title": "Enable Keyboard",
"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_set": "Failed to set USB devices: {error}",
"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_restore_default": "Restore to Default",
"usb_device_title": "USB Device",

View File

@ -47,6 +47,7 @@
"access_tls_self_signed": "Autofirmado",
"access_tls_updated": "La configuración de TLS se actualizó correctamente",
"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_extension": "Extensión",
"action_bar_fullscreen": "Pantalla completa",
@ -74,7 +75,6 @@
"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_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_title": "Modo de solo bucle invertido",
"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_usb_emulation_description": "Controlar el estado de 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_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",
@ -134,6 +121,50 @@
"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_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_error": "Se produjo un error al configurar el modo de autenticación",
"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_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_description": "La dirección IP del dispositivo remoto.",
"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_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_error": "No se pudo configurar la actualización automática: {error}",
"general_auto_update_title": "Actualización automática",
"general_check_for_stable_updates": "Degradar",
"general_check_for_updates": "Buscar actualizaciones",
"general_page_description": "Configurar los ajustes del dispositivo y actualizar las preferencias",
"general_reboot_description": "¿Desea continuar con el reinicio del sistema?",
@ -280,13 +309,9 @@
"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_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_details": "Detalles del error: {errorMessage}",
"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_now_button": "Actualizar ahora",
"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_updating_description": "No apagues tu dispositivo. Este proceso puede tardar unos minutos.",
"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}",
"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}",
@ -817,6 +841,8 @@
"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_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_title": "Habilitar el teclado",
"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_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_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
"usb_device_keyboard_only": "Sólo teclado",
"usb_device_restore_default": "Restaurar a valores predeterminados",
"usb_device_title": "Dispositivo USB",

View File

@ -47,6 +47,7 @@
"access_tls_self_signed": "Auto-signé",
"access_tls_updated": "Les paramètres TLS ont été mis à jour avec succès",
"access_update_tls_settings": "Mettre à jour les paramètres TLS",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Statistiques de connexion",
"action_bar_extension": "Extension",
"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_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_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_title": "Mode de bouclage uniquement",
"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_usb_emulation_description": "Contrôler l'état de l'é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_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",
@ -134,6 +121,50 @@
"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_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_error": "Une erreur s'est produite lors de la définition du mode d'authentification",
"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_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_description": "L'adresse IP du périphérique distant.",
"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_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_error": "Échec de la définition de la mise à jour automatique : {error}",
"general_auto_update_title": "Mise à jour automatique",
"general_check_for_stable_updates": "Rétrograder",
"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_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_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_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_details": "Détails de l'erreur : {errorMessage}",
"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_now_button": "Mettre à jour maintenant",
"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_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_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",
"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}",
@ -817,6 +841,8 @@
"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_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_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",
@ -826,6 +852,7 @@
"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_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_restore_default": "Restaurer les paramètres par défaut",
"usb_device_title": "périphérique USB",

View File

@ -47,6 +47,7 @@
"access_tls_self_signed": "Autofirmato",
"access_tls_updated": "Impostazioni TLS aggiornate correttamente",
"access_update_tls_settings": "Aggiorna le impostazioni TLS",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Statistiche di connessione",
"action_bar_extension": "Estensione",
"action_bar_fullscreen": "A schermo intero",
@ -74,7 +75,6 @@
"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_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_title": "Modalità solo loopback",
"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_usb_emulation_description": "Controlla lo stato di 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_other_user": "Questo dispositivo è attualmente registrato a un altro utente nella nostra dashboard cloud.",
"already_adopted_return_to_dashboard": "Torna alla dashboard",
@ -134,6 +121,50 @@
"atx_power_control_reset_button": "Reset",
"atx_power_control_send_action_error": "Impossibile inviare l'azione di alimentazione ATX {action} : {error}",
"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_error": "Si è verificato un errore durante l'impostazione della modalità di autenticazione",
"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_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_description": "L'indirizzo IP del dispositivo remoto.",
"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_sidebar": "Statistiche di connessione",
@ -259,7 +289,6 @@
"general_auto_update_description": "Aggiorna automaticamente il dispositivo all'ultima versione",
"general_auto_update_error": "Impossibile impostare l'aggiornamento automatico: {error}",
"general_auto_update_title": "Aggiornamento automatico",
"general_check_for_stable_updates": "Declassare",
"general_check_for_updates": "Verifica aggiornamenti",
"general_page_description": "Configurare le impostazioni del dispositivo e aggiornare le preferenze",
"general_reboot_description": "Vuoi procedere con il riavvio del sistema?",
@ -280,13 +309,9 @@
"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_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_details": "Dettagli errore: {errorMessage}",
"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_now_button": "Aggiorna ora",
"general_update_rebooting": "Riavvio per completare l'aggiornamento…",
@ -302,7 +327,6 @@
"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_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}",
"hardware_backlight_settings_error": "Impossibile impostare 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_enable_absolute_mouse_description": "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_title": "Abilita tastiera",
"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_set": "Impossibile impostare i dispositivi USB: {error}",
"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_restore_default": "Ripristina impostazioni predefinite",
"usb_device_title": "Dispositivo USB",

View File

@ -47,6 +47,7 @@
"access_tls_self_signed": "Selvsignert",
"access_tls_updated": "TLS-innstillingene er oppdatert",
"access_update_tls_settings": "Oppdater TLS-innstillinger",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Tilkoblingsstatistikk",
"action_bar_extension": "Forlengelse",
"action_bar_fullscreen": "Fullskjerm",
@ -74,7 +75,6 @@
"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_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_title": "Kun lokal tilgang",
"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_usb_emulation_description": "Kontroller USB-emuleringstilstanden",
"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_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",
@ -134,6 +121,50 @@
"atx_power_control_reset_button": "Tilbakestill",
"atx_power_control_send_action_error": "Kunne ikke sende ATX-strømhandling {action} : {error}",
"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_error": "Det oppsto en feil under angivelse av autentiseringsmodus",
"auth_authentication_mode_invalid": "Ugyldig autentiseringsmodus",
@ -193,7 +224,6 @@
"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_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_description": "Rundturstid for det aktive ICE-kandidatparet mellom jevnaldrende.",
"connection_stats_sidebar": "Tilkoblingsstatistikk",
@ -259,7 +289,6 @@
"general_auto_update_description": "Oppdater enheten automatisk til den nyeste versjonen",
"general_auto_update_error": "Klarte ikke å angi automatisk oppdatering: {error}",
"general_auto_update_title": "Automatisk oppdatering",
"general_check_for_stable_updates": "Nedgrader",
"general_check_for_updates": "Se etter oppdateringer",
"general_page_description": "Konfigurer enhetsinnstillinger og oppdater preferanser",
"general_reboot_description": "Vil du fortsette med å starte systemet på nytt?",
@ -280,13 +309,9 @@
"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_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_details": "Feildetaljer: {errorMessage}",
"general_update_error_title": "Oppdateringsfeil",
"general_update_keep_current_button": "Behold gjeldende versjon",
"general_update_later_button": "Oppdater senere",
"general_update_now_button": "Oppdater nå",
"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_updating_description": "Ikke slå av enheten. Denne prosessen kan ta noen minutter.",
"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}",
"hardware_backlight_settings_error": "Kunne ikke angi innstillinger for bakgrunnsbelysning: {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_enable_absolute_mouse_description": "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_title": "Aktiver tastatur",
"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_set": "Kunne ikke angi USB-enheter: {error}",
"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_restore_default": "Gjenopprett til standard",
"usb_device_title": "USB-enhet",

View File

@ -47,7 +47,7 @@
"access_tls_self_signed": "Självsignerad",
"access_tls_updated": "TLS-inställningarna har uppdaterats",
"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_fullscreen": "Helskärm",
"action_bar_settings": "Inställningar",
@ -74,7 +74,6 @@
"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_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_title": "Loopback-läge",
"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_usb_emulation_description": "Kontrollera USB-emuleringsstatusen",
"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_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",
@ -134,6 +120,50 @@
"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_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_error": "Ett fel uppstod när autentiseringsläget ställdes in",
"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_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_description": "IP-adressen för den fjärranslutna enheten.",
"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_sidebar": "Anslutningsstatistik",
@ -259,7 +288,6 @@
"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_title": "Automatisk uppdatering",
"general_check_for_stable_updates": "Nedvärdera",
"general_check_for_updates": "Kontrollera efter uppdateringar",
"general_page_description": "Konfigurera enhetsinställningar och uppdatera inställningar",
"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_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_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_details": "Felinformation: {errorMessage}",
"general_update_error_title": "Uppdateringsfel",
"general_update_keep_current_button": "Behåll aktuell version",
"general_update_later_button": "Gör det senare",
"general_update_now_button": "Uppdatera nu",
"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_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_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}",
"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}",
@ -817,6 +840,8 @@
"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_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_title": "Aktivera tangentbord",
"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_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_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
"usb_device_keyboard_only": "Endast tangentbord",
"usb_device_restore_default": "Återställ till standard",
"usb_device_title": "USB-enhet",

View File

@ -47,6 +47,7 @@
"access_tls_self_signed": "自签名",
"access_tls_updated": "TLS 设置更新成功",
"access_update_tls_settings": "更新 TLS 设置",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "连接统计",
"action_bar_extension": "扩展",
"action_bar_fullscreen": "全屏",
@ -74,7 +75,6 @@
"advanced_error_update_ssh_key": "无法更新 SSH 密钥: {error}",
"advanced_error_usb_emulation_disable": "无法禁用 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_title": "仅环回模式",
"advanced_loopback_warning_before": "在启用此功能之前,请确保您已:",
@ -101,19 +101,6 @@
"advanced_update_ssh_key_button": "更新 SSH 密钥",
"advanced_usb_emulation_description": "控制 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_other_user": "该设备目前已在我们的云仪表板中注册给另一个用户。",
"already_adopted_return_to_dashboard": "返回仪表板",
@ -134,6 +121,50 @@
"atx_power_control_reset_button": "重置",
"atx_power_control_send_action_error": "无法发送 ATX 电源操作 {action} : {error}",
"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_error": "设置身份验证模式时发生错误",
"auth_authentication_mode_invalid": "身份验证模式无效",
@ -190,10 +221,9 @@
"connection_stats_packets_lost_description": "丢失的入站视频 RTP 数据包的数量。",
"connection_stats_playback_delay": "播放延迟",
"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_success": "远程 IP 地址{ ip }已复制到剪贴板",
"connection_stats_remote_ip_address_description": "远程设备的IP地址。",
"connection_stats_round_trip_time": "往返时间",
"connection_stats_round_trip_time_description": "对等体之间活跃 ICE 候选对的往返时间。",
"connection_stats_sidebar": "连接统计",
@ -259,7 +289,6 @@
"general_auto_update_description": "自动将设备更新到最新版本",
"general_auto_update_error": "无法设置自动更新: {error}",
"general_auto_update_title": "自动更新",
"general_check_for_stable_updates": "降级",
"general_check_for_updates": "检查更新",
"general_page_description": "配置设备设置并更新首选项",
"general_reboot_description": "您想继续重新启动系统吗?",
@ -280,13 +309,9 @@
"general_update_checking_title": "正在检查更新…",
"general_update_completed_description": "您的设备已成功更新至最新版本。尽情享受新功能和改进吧!",
"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_details": "错误详细信息: {errorMessage}",
"general_update_error_title": "更新错误",
"general_update_keep_current_button": "保持当前版本",
"general_update_later_button": "稍后再说",
"general_update_now_button": "立即更新",
"general_update_rebooting": "重新启动以完成更新…",
@ -302,7 +327,6 @@
"general_update_up_to_date_title": "系统已更新",
"general_update_updating_description": "正在更新,请勿关闭设备。该过程可能需要数分钟。",
"general_update_updating_title": "更新您的设备",
"general_update_will_disable_auto_update_description": "您即将手动更改设备版本。更新完成后,自动更新功能将被禁用,以防止意外更新。",
"getting_remote_session_description": "获取远程会话描述尝试 {attempt}",
"hardware_backlight_settings_error": "无法设置背光设置: {error}",
"hardware_backlight_settings_get_error": "无法获取背光设置: {error}",
@ -817,6 +841,8 @@
"usb_device_description": "在目标计算机上仿真的 USB 设备",
"usb_device_enable_absolute_mouse_description": "启用绝对鼠标(指针)",
"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_title": "启用键盘",
"usb_device_enable_mass_storage_description": "有时可能需要禁用它以防止某些设备出现问题",
@ -826,6 +852,7 @@
"usb_device_failed_load": "无法加载 USB 设备: {error}",
"usb_device_failed_set": "无法设置 USB 设备: {error}",
"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_restore_default": "恢复默认设置",
"usb_device_title": "USB 设备",

View File

@ -1,6 +1,6 @@
import { Fragment, useCallback, useRef } from "react";
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 { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
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 MountPopopover from "@components/popovers/MountPopover";
import ExtensionPopover from "@components/popovers/ExtensionPopover";
import AudioPopover from "@components/popovers/AudioPopover";
import { m } from "@localizations/messages.js";
export default function Actionbar({
@ -201,6 +202,36 @@ export default function Actionbar({
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/>
</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 className="flex flex-wrap items-center gap-x-2 gap-y-2">

View File

@ -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";
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 {
readonly title: string;
readonly description: string | React.ReactNode;
readonly badge?: string;
readonly badgeTheme?: keyof typeof badgeTheme;
readonly badgeVariant?: "error" | "info";
readonly badgeLink?: string;
readonly className?: string;
readonly loading?: boolean;
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) {
const { title, description, badge, badgeTheme: badgeThemeProp = "danger", children, className, loading } = props;
const badgeThemeClass = badgeTheme[badgeThemeProp];
const { title, description, badge, badgeVariant = "error", badgeLink, children, className, loading } = props;
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 (
<label
@ -33,11 +52,7 @@ export function SettingsItem(props: SettingsItemProps) {
<div className="flex items-center gap-x-2">
<div className="flex items-center text-base font-semibold text-black dark:text-white">
{title}
{badge && (
<span className={cx("ml-2 rounded-full px-2 py-1 text-[10px] font-medium leading-none text-white", badgeThemeClass)}>
{badge}
</span>
)}
{badgeContent}
</div>
{loading && <LoadingSpinner className="h-4 w-4 text-blue-500" />}
</div>

View File

@ -24,6 +24,7 @@ export interface UsbDeviceConfig {
absolute_mouse: boolean;
relative_mouse: boolean;
mass_storage: boolean;
audio: boolean;
}
const defaultUsbDeviceConfig: UsbDeviceConfig = {
@ -31,17 +32,30 @@ const defaultUsbDeviceConfig: UsbDeviceConfig = {
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
audio: true,
};
const usbPresets = [
{
label: m.usb_device_keyboard_mouse_and_mass_storage(),
label: m.usb_device_keyboard_mouse_mass_storage_and_audio(),
value: "default",
config: {
keyboard: true,
absolute_mouse: true,
relative_mouse: 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,
relative_mouse: false,
mass_storage: false,
audio: false,
},
},
{
@ -219,6 +234,17 @@ export function UsbDeviceSetting() {
/>
</SettingsItem>
</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 className="mt-6 flex gap-x-2">
<Button

View File

@ -22,17 +22,20 @@ import {
import { keys } from "@/keyboardMappings";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
import { isSecureContext } from "@/utils";
export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssues: boolean }) {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const audioElementsRef = useRef<HTMLAudioElement[]>([]);
const fullscreenContainerRef = useRef<HTMLDivElement>(null);
const { mediaStream, peerConnectionState } = useRTCStore();
const [isPlaying, setIsPlaying] = useState(false);
const [audioAutoplayBlocked, setAudioAutoplayBlocked] = useState(false);
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false);
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
const isPointerLockPossible = isSecureContext();
// Store hooks
const settings = useSettingsStore();
@ -335,13 +338,34 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
peerConnection.addEventListener(
"track",
(e: RTCTrackEvent) => {
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 },
);
return () => {
abortController.abort();
audioElementsRef.current.forEach((audioElm) => {
audioElm.srcObject = null;
audioElm.remove();
});
audioElementsRef.current = [];
setAudioAutoplayBlocked(false);
};
},
[addStreamToVideoElm, peerConnection],
@ -455,11 +479,12 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
const hasNoAutoPlayPermissions = useMemo(() => {
if (peerConnection?.connectionState !== "connected") return false;
if (isPlaying) return false;
if (hdmiError) return false;
if (videoHeight === 0 || videoWidth === 0) return false;
return true;
}, [hdmiError, isPlaying, peerConnection?.connectionState, videoHeight, videoWidth]);
if (!isPlaying) return true;
if (audioAutoplayBlocked) return true;
return false;
}, [audioAutoplayBlocked, hdmiError, isPlaying, peerConnection?.connectionState, videoHeight, videoWidth]);
const showPointerLockBar = useMemo(() => {
if (settings.mouseMode !== "relative") return false;
@ -523,7 +548,6 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
controls={false}
onPlaying={onVideoPlaying}
onPlay={onVideoPlaying}
muted
playsInline
disablePictureInPicture
controlsList="nofullscreen"
@ -555,6 +579,11 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
show={hasNoAutoPlayPermissions}
onPlayClick={() => {
videoElm.current?.play();
audioElementsRef.current.forEach(audioElm => {
audioElm.play().then(() => {
setAudioAutoplayBlocked(false);
}).catch(() => undefined);
});
}}
/>
</div>

View File

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

View File

@ -140,6 +140,9 @@ export interface RTCState {
transceiver: RTCRtpTransceiver | null;
setTransceiver: (transceiver: RTCRtpTransceiver) => void;
audioTransceiver: RTCRtpTransceiver | null;
setAudioTransceiver: (transceiver: RTCRtpTransceiver) => void;
mediaStream: MediaStream | null;
setMediaStream: (stream: MediaStream) => void;
@ -199,6 +202,9 @@ export const useRTCStore = create<RTCState>(set => ({
transceiver: null,
setTransceiver: transceiver => set({ transceiver }),
audioTransceiver: null,
setAudioTransceiver: (transceiver: RTCRtpTransceiver) => set({ audioTransceiver: transceiver }),
peerConnectionState: null,
setPeerConnectionState: state => set({ peerConnectionState: state }),
@ -375,6 +381,34 @@ export interface SettingsState {
videoContrast: number;
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(
@ -422,6 +456,32 @@ export const useSettingsStore = create(
videoContrast: 1.0,
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",

View File

@ -39,6 +39,7 @@ const blockedMethodsByReason: Record<string, string[]> = {
video: [
'setStreamQualityFactor',
'getEDID',
'getDefaultEDID',
'setEDID',
'getVideoLogStatus',
'setDisplayRotation',

View File

@ -41,6 +41,7 @@ const SettingsKeyboardRoute = lazy(() => import("@routes/devices.$id.settings.ke
const SettingsAdvancedRoute = lazy(() => import("@routes/devices.$id.settings.advanced"));
const SettingsHardwareRoute = lazy(() => import("@routes/devices.$id.settings.hardware"));
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 SettingsGeneralIndexRoute = lazy(() => import("@routes/devices.$id.settings.general._index"));
const SettingsGeneralRebootRoute = lazy(() => import("@routes/devices.$id.settings.general.reboot"));
@ -191,6 +192,10 @@ if (isOnDevice) {
path: "video",
element: <SettingsVideoRoute />,
},
{
path: "audio",
element: <SettingsAudioRoute />,
},
{
path: "appearance",
element: <SettingsAppearanceRoute />,
@ -324,6 +329,10 @@ if (isOnDevice) {
path: "video",
element: <SettingsVideoRoute />,
},
{
path: "audio",
element: <SettingsAudioRoute />,
},
{
path: "appearance",
element: <SettingsAppearanceRoute />,

View File

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

View File

@ -85,7 +85,7 @@ export default function SettingsGeneralRoute() {
<div className="space-y-4">
<SettingsItem
badge="Beta"
badgeTheme="info"
badgeVariant="info"
title={m.user_interface_language_title()}
description={m.user_interface_language_description()}
>

View File

@ -6,6 +6,7 @@ import {
LuMouse,
LuKeyboard,
LuVideo,
LuVolume2,
LuCpu,
LuShieldCheck,
LuWrench,
@ -178,6 +179,17 @@ export default function SettingsRoute() {
<div className={cx("shrink-0", {
"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
to="hardware"
className={({ isActive }) => cx(isActive ? "active" : "", {

View File

@ -12,13 +12,7 @@ import Fieldset from "@components/Fieldset";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
const defaultEdid =
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
const edids = [
{
value: defaultEdid,
label: m.video_edid_jetkvm_default(),
},
const otherEdids = [
{
value:
"00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096",
@ -53,6 +47,8 @@ export default function SettingsVideoRoute() {
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null);
const [edidLoading, setEdidLoading] = useState(true);
const [defaultEdid, setDefaultEdid] = useState<string>("");
const [edids, setEdids] = useState<{value: string, label: string}[]>([]);
const { debugMode } = useSettingsStore();
// Video enhancement settings from store
const {
@ -70,6 +66,21 @@ export default function SettingsVideoRoute() {
setStreamQuality(String(resp.result));
});
send("getDefaultEDID", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(m.video_failed_get_edid({ error: resp.error.data || m.unknown_error() }));
return;
}
const fetchedDefaultEdid = resp.result as string;
setDefaultEdid(fetchedDefaultEdid);
const allEdids = [
{ value: fetchedDefaultEdid, label: m.video_edid_jetkvm_default() },
...otherEdids
];
setEdids(allEdids);
send("getEDID", {}, (resp: JsonRpcResponse) => {
setEdidLoading(false);
if ("error" in resp) {
@ -79,20 +90,19 @@ export default function SettingsVideoRoute() {
const receivedEdid = resp.result as string;
const matchingEdid = edids.find(
const matchingEdid = allEdids.find(
x => x.value.toLowerCase() === receivedEdid.toLowerCase(),
);
if (matchingEdid) {
// EDID is stored in uppercase in the UI
setEdid(matchingEdid.value.toUpperCase());
// Reset custom EDID value
setCustomEdidValue(null);
} else {
setEdid("custom");
setCustomEdidValue(receivedEdid);
}
});
});
}, [send]);
const handleStreamQualityChange = (factor: string) => {

View File

@ -15,7 +15,7 @@ import { motion, AnimatePresence } from "framer-motion";
import useWebSocket from "react-use-websocket";
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 { checkAuth, isInCloud, isOnDevice } from "@/main";
import {
@ -29,6 +29,7 @@ import {
useNetworkStateStore,
User,
useRTCStore,
useSettingsStore,
useUiStore,
useUpdateStore,
useVideoStore,
@ -53,6 +54,7 @@ import {
} from "@components/VideoOverlay";
import { FeatureFlagProvider } from "@providers/FeatureFlagProvider";
import { m } from "@localizations/messages.js";
import { isSecureContext } from "@/utils";
import { doRpcHidHandshake } from "@hooks/useHidRpc";
export type AuthMode = "password" | "noPassword" | null;
@ -115,6 +117,7 @@ export default function KvmIdRoute() {
const params = useParams() as { id: string };
const { sidebarView, setSidebarView, disableVideoFocusTrap, rebootState, setRebootState } = useUiStore();
const { microphoneEnabled, setMicrophoneEnabled, audioInputAutoEnable, setAudioInputAutoEnable } = useSettingsStore();
const [queryParams, setQueryParams] = useSearchParams();
const {
@ -125,6 +128,8 @@ export default function KvmIdRoute() {
isTurnServerInUse, setTurnServerInUse,
rpcDataChannel,
setTransceiver,
setAudioTransceiver,
audioTransceiver,
setRpcHidChannel,
setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel,
@ -177,6 +182,30 @@ export default function KvmIdRoute() {
) {
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 {
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
console.log("[setRemoteSessionDescription] Remote description set successfully to: " + remoteDescription.sdp);
@ -439,6 +468,29 @@ export default function KvmIdRoute() {
makingOffer.current = true;
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);
const sd = btoa(JSON.stringify(pc.localDescription));
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
@ -481,11 +533,16 @@ export default function KvmIdRoute() {
};
pc.ontrack = function (event) {
if (event.track.kind === "video") {
setMediaStream(event.streams[0]);
}
};
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
const audioTrans = pc.addTransceiver("audio", { direction: "sendrecv" });
setAudioTransceiver(audioTrans);
const rpcDataChannel = pc.createDataChannel("rpc");
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
rpcDataChannel.onerror = (ev: Event) => console.error(`Error on DataChannel '${rpcDataChannel.label}': ${ev}`);
@ -539,6 +596,9 @@ export default function KvmIdRoute() {
setRpcHidUnreliableChannel,
setRpcHidProtocolVersion,
setTransceiver,
setAudioTransceiver,
audioInputAutoEnable,
setMicrophoneEnabled,
]);
useEffect(() => {
@ -548,6 +608,66 @@ export default function KvmIdRoute() {
}
}, [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
const { clearInboundRtpStats, clearCandidatePairStats } = useRTCStore();
@ -721,6 +841,7 @@ export default function KvmIdRoute() {
const { send } = useJsonRpc(onJsonRpcRequest);
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
console.log("Requesting video state");
@ -732,6 +853,46 @@ export default function KvmIdRoute() {
});
}, [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);
// request keyboard led state from the device

View File

@ -16,8 +16,11 @@ import { DeviceStatus } from "@routes/welcome-local";
import { DEVICE_API } from "@/ui.config";
import api from "@/api";
import { m } from "@localizations/messages.js";
import { useSettingsStore } from "@/hooks/stores";
const loader: LoaderFunction = async () => {
useSettingsStore.getState().resetMicrophoneState();
const res = await api
.GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);

View File

@ -1,13 +1,19 @@
import { useEffect } from "react";
import { useLocation, useSearchParams } from "react-router";
import { m } from "@localizations/messages.js";
import AuthLayout from "@components/AuthLayout";
import { useSettingsStore } from "@/hooks/stores";
export default function LoginRoute() {
const [sq] = useSearchParams();
const location = useLocation();
const deviceId = sq.get("deviceId") || location.state?.deviceId;
useEffect(() => {
useSettingsStore.getState().resetMicrophoneState();
}, []);
if (deviceId) {
return (
<AuthLayout

View File

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

View File

@ -301,3 +301,7 @@ export function deleteCookie(name: string, domain?: string, path = "/") {
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function isSecureContext(): boolean {
return window.location.protocol === "https:" || window.location.hostname === "localhost";
}

19
usb.go
View File

@ -9,7 +9,6 @@ import (
var gadget *usbgadget.UsbGadget
// initUsbGadget initializes the USB gadget.
// call it only after the config is loaded.
func initUsbGadget() {
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 {
usbLogger.Error().Err(err).Msg("failed to open keyboard hid file")
}
@ -109,8 +107,23 @@ func checkUSBState() {
return
}
oldState := usbState
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")
triggerUSBStateUpdate()

View File

@ -22,6 +22,7 @@ import (
type Session struct {
peerConnection *webrtc.PeerConnection
VideoTrack *webrtc.TrackLocalStaticSample
AudioTrack *webrtc.TrackLocalStaticSample
ControlChannel *webrtc.DataChannel
RPCChannel *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
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
@ -357,6 +392,8 @@ func newSession(config SessionConfig) (*Session, error) {
}
if connectionState == webrtc.ICEConnectionStateClosed {
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 {
// Cancel any ongoing keyboard report multi when session closes
cancelKeyboardMacro()
@ -403,10 +440,12 @@ func onActiveSessionsChanged() {
func onFirstSessionConnected() {
notifyFailsafeMode(currentSession)
_ = nativeInstance.VideoStart()
onWebRTCConnect()
stopVideoSleepModeTicker()
}
func onLastSessionDisconnected() {
_ = nativeInstance.VideoStop()
onWebRTCDisconnect()
startVideoSleepModeTicker()
}