mirror of https://github.com/jetkvm/kvm.git
Compare commits
6 Commits
3c32336f9d
...
ef4b5572be
| Author | SHA1 | Date |
|---|---|---|
|
|
ef4b5572be | |
|
|
2f7374f748 | |
|
|
a84f63c0c4 | |
|
|
439f57c3c8 | |
|
|
b6d093f399 | |
|
|
cd87aa499c |
|
|
@ -2,12 +2,9 @@ package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/jetkvm/kvm/internal/audio"
|
"github.com/jetkvm/kvm/internal/audio"
|
||||||
"github.com/pion/webrtc/v4"
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -18,203 +15,17 @@ func ensureAudioControlService() *audio.AudioControlService {
|
||||||
sessionProvider := &SessionProviderImpl{}
|
sessionProvider := &SessionProviderImpl{}
|
||||||
audioControlService = audio.NewAudioControlService(sessionProvider, logger)
|
audioControlService = audio.NewAudioControlService(sessionProvider, logger)
|
||||||
|
|
||||||
// Set up callback for audio relay to get current session's audio track
|
// Set up RPC callback functions for the audio package
|
||||||
audio.SetCurrentSessionCallback(func() audio.AudioTrackWriter {
|
audio.SetRPCCallbacks(
|
||||||
return GetCurrentSessionAudioTrack()
|
func() *audio.AudioControlService { return audioControlService },
|
||||||
})
|
func() audio.AudioConfig { return audioControlService.GetCurrentAudioQuality() },
|
||||||
|
func(quality audio.AudioQuality) error {
|
||||||
// Set up callback for audio relay to replace WebRTC audio track
|
|
||||||
audio.SetTrackReplacementCallback(func(newTrack audio.AudioTrackWriter) error {
|
|
||||||
if track, ok := newTrack.(*webrtc.TrackLocalStaticSample); ok {
|
|
||||||
return ReplaceCurrentSessionAudioTrack(track)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return audioControlService
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Global Convenience Functions for Audio Control ---
|
|
||||||
|
|
||||||
// MuteAudioOutput is a global helper to mute audio output
|
|
||||||
func MuteAudioOutput() error {
|
|
||||||
return ensureAudioControlService().MuteAudio(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmuteAudioOutput is a global helper to unmute audio output
|
|
||||||
func UnmuteAudioOutput() error {
|
|
||||||
return ensureAudioControlService().MuteAudio(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopMicrophone is a global helper to stop microphone subprocess
|
|
||||||
func StopMicrophone() error {
|
|
||||||
return ensureAudioControlService().StopMicrophone()
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartMicrophone is a global helper to start microphone subprocess
|
|
||||||
func StartMicrophone() error {
|
|
||||||
return ensureAudioControlService().StartMicrophone()
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsAudioOutputActive is a global helper to check if audio output subprocess is running
|
|
||||||
func IsAudioOutputActive() bool {
|
|
||||||
return ensureAudioControlService().IsAudioOutputActive()
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsMicrophoneActive is a global helper to check if microphone subprocess is running
|
|
||||||
func IsMicrophoneActive() bool {
|
|
||||||
return ensureAudioControlService().IsMicrophoneActive()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetMicrophone is a global helper to reset the microphone
|
|
||||||
func ResetMicrophone() error {
|
|
||||||
return ensureAudioControlService().ResetMicrophone()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentSessionAudioTrack returns the current session's audio track for audio relay
|
|
||||||
func GetCurrentSessionAudioTrack() *webrtc.TrackLocalStaticSample {
|
|
||||||
if currentSession != nil {
|
|
||||||
return currentSession.AudioTrack
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConnectRelayToCurrentSession connects the audio relay to the current WebRTC session
|
|
||||||
func ConnectRelayToCurrentSession() error {
|
|
||||||
if currentTrack := GetCurrentSessionAudioTrack(); currentTrack != nil {
|
|
||||||
err := audio.UpdateAudioRelayTrack(currentTrack)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error().Err(err).Msg("failed to connect current session's audio track to relay")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logger.Info().Msg("connected current session's audio track to relay")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
logger.Warn().Msg("no current session audio track found")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplaceCurrentSessionAudioTrack replaces the audio track in the current WebRTC session
|
|
||||||
func ReplaceCurrentSessionAudioTrack(newTrack *webrtc.TrackLocalStaticSample) error {
|
|
||||||
if currentSession == nil {
|
|
||||||
return nil // No session to update
|
|
||||||
}
|
|
||||||
|
|
||||||
err := currentSession.ReplaceAudioTrack(newTrack)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error().Err(err).Msg("failed to replace audio track in current session")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info().Msg("successfully replaced audio track in current session")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetAudioQuality is a global helper to set audio output quality
|
|
||||||
func SetAudioQuality(quality audio.AudioQuality) error {
|
|
||||||
ensureAudioControlService()
|
|
||||||
audioControlService.SetAudioQuality(quality)
|
audioControlService.SetAudioQuality(quality)
|
||||||
return nil
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
return audioControlService
|
||||||
// GetAudioQualityPresets is a global helper to get available audio quality presets
|
|
||||||
func GetAudioQualityPresets() map[audio.AudioQuality]audio.AudioConfig {
|
|
||||||
ensureAudioControlService()
|
|
||||||
return audioControlService.GetAudioQualityPresets()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentAudioQuality is a global helper to get current audio quality configuration
|
|
||||||
func GetCurrentAudioQuality() audio.AudioConfig {
|
|
||||||
ensureAudioControlService()
|
|
||||||
return audioControlService.GetCurrentAudioQuality()
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAudioMute handles POST /audio/mute requests
|
|
||||||
func handleAudioMute(c *gin.Context) {
|
|
||||||
type muteReq struct {
|
|
||||||
Muted bool `json:"muted"`
|
|
||||||
}
|
|
||||||
var req muteReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(400, gin.H{"error": "invalid request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
if req.Muted {
|
|
||||||
err = MuteAudioOutput()
|
|
||||||
} else {
|
|
||||||
err = UnmuteAudioOutput()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
|
||||||
"status": "audio mute state updated",
|
|
||||||
"muted": req.Muted,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleMicrophoneStart handles POST /microphone/start requests
|
|
||||||
func handleMicrophoneStart(c *gin.Context) {
|
|
||||||
err := StartMicrophone()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleMicrophoneStop handles POST /microphone/stop requests
|
|
||||||
func handleMicrophoneStop(c *gin.Context) {
|
|
||||||
err := StopMicrophone()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleMicrophoneMute handles POST /microphone/mute requests
|
|
||||||
func handleMicrophoneMute(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Muted bool `json:"muted"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
if req.Muted {
|
|
||||||
err = StopMicrophone()
|
|
||||||
} else {
|
|
||||||
err = StartMicrophone()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleMicrophoneReset handles POST /microphone/reset requests
|
|
||||||
func handleMicrophoneReset(c *gin.Context) {
|
|
||||||
err := ResetMicrophone()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSubscribeAudioEvents handles WebSocket audio event subscription
|
// handleSubscribeAudioEvents handles WebSocket audio event subscription
|
||||||
|
|
@ -228,57 +39,3 @@ func handleUnsubscribeAudioEvents(connectionID string, l *zerolog.Logger) {
|
||||||
ensureAudioControlService()
|
ensureAudioControlService()
|
||||||
audioControlService.UnsubscribeFromAudioEvents(connectionID, l)
|
audioControlService.UnsubscribeFromAudioEvents(connectionID, l)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAudioStatus handles GET requests for audio status
|
|
||||||
func handleAudioStatus(c *gin.Context) {
|
|
||||||
ensureAudioControlService()
|
|
||||||
|
|
||||||
status := audioControlService.GetAudioStatus()
|
|
||||||
c.JSON(200, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAudioQuality handles GET requests for audio quality presets
|
|
||||||
func handleAudioQuality(c *gin.Context) {
|
|
||||||
presets := GetAudioQualityPresets()
|
|
||||||
current := GetCurrentAudioQuality()
|
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
|
||||||
"presets": presets,
|
|
||||||
"current": current,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSetAudioQuality handles POST requests to set audio quality
|
|
||||||
func handleSetAudioQuality(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Quality int `json:"quality"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if audio output is active before attempting quality change
|
|
||||||
// This prevents race conditions where quality changes are attempted before initialization
|
|
||||||
if !IsAudioOutputActive() {
|
|
||||||
c.JSON(503, gin.H{"error": "audio output not active - please wait for initialization to complete"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert int to AudioQuality type
|
|
||||||
quality := audio.AudioQuality(req.Quality)
|
|
||||||
|
|
||||||
// Set the audio quality using global convenience function
|
|
||||||
if err := SetAudioQuality(quality); err != nil {
|
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the updated configuration
|
|
||||||
current := GetCurrentAudioQuality()
|
|
||||||
c.JSON(200, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"config": current,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
11
cloud.go
11
cloud.go
|
|
@ -20,6 +20,7 @@ import (
|
||||||
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jetkvm/kvm/internal/audio"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -480,6 +481,16 @@ func handleSessionRequest(
|
||||||
cancelKeyboardMacro()
|
cancelKeyboardMacro()
|
||||||
|
|
||||||
currentSession = session
|
currentSession = session
|
||||||
|
|
||||||
|
// Set up audio relay callback to get current session's audio track
|
||||||
|
// This is needed for audio output to work after enable/disable cycles
|
||||||
|
audio.SetCurrentSessionCallback(func() audio.AudioTrackWriter {
|
||||||
|
if currentSession != nil {
|
||||||
|
return currentSession.AudioTrack
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
|
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RPC wrapper functions for audio control
|
||||||
|
// These functions bridge the RPC layer to the AudioControlService
|
||||||
|
|
||||||
|
// These variables will be set by the main package to provide access to the global service
|
||||||
|
var (
|
||||||
|
getAudioControlServiceFunc func() *AudioControlService
|
||||||
|
getAudioQualityFunc func() AudioConfig
|
||||||
|
setAudioQualityFunc func(AudioQuality) error
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetRPCCallbacks sets the callback functions for RPC operations
|
||||||
|
func SetRPCCallbacks(
|
||||||
|
getService func() *AudioControlService,
|
||||||
|
getQuality func() AudioConfig,
|
||||||
|
setQuality func(AudioQuality) error,
|
||||||
|
) {
|
||||||
|
getAudioControlServiceFunc = getService
|
||||||
|
getAudioQualityFunc = getQuality
|
||||||
|
setAudioQualityFunc = setQuality
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCAudioMute handles audio mute/unmute RPC requests
|
||||||
|
func RPCAudioMute(muted bool) error {
|
||||||
|
if getAudioControlServiceFunc == nil {
|
||||||
|
return fmt.Errorf("audio control service not available")
|
||||||
|
}
|
||||||
|
service := getAudioControlServiceFunc()
|
||||||
|
if service == nil {
|
||||||
|
return fmt.Errorf("audio control service not initialized")
|
||||||
|
}
|
||||||
|
return service.MuteAudio(muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCAudioQuality handles audio quality change RPC requests
|
||||||
|
func RPCAudioQuality(quality int) (map[string]any, error) {
|
||||||
|
if getAudioQualityFunc == nil || setAudioQualityFunc == nil {
|
||||||
|
return nil, fmt.Errorf("audio quality functions not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert int to AudioQuality type
|
||||||
|
audioQuality := AudioQuality(quality)
|
||||||
|
|
||||||
|
// Get current audio quality configuration
|
||||||
|
currentConfig := getAudioQualityFunc()
|
||||||
|
|
||||||
|
// Set new quality if different
|
||||||
|
if currentConfig.Quality != audioQuality {
|
||||||
|
err := setAudioQualityFunc(audioQuality)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set audio quality: %w", err)
|
||||||
|
}
|
||||||
|
// Get updated config after setting
|
||||||
|
newConfig := getAudioQualityFunc()
|
||||||
|
return map[string]any{"config": newConfig}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return current config if no change needed
|
||||||
|
return map[string]any{"config": currentConfig}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCMicrophoneStart handles microphone start RPC requests
|
||||||
|
func RPCMicrophoneStart() error {
|
||||||
|
if getAudioControlServiceFunc == nil {
|
||||||
|
return fmt.Errorf("audio control service not available")
|
||||||
|
}
|
||||||
|
service := getAudioControlServiceFunc()
|
||||||
|
if service == nil {
|
||||||
|
return fmt.Errorf("audio control service not initialized")
|
||||||
|
}
|
||||||
|
return service.StartMicrophone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCMicrophoneStop handles microphone stop RPC requests
|
||||||
|
func RPCMicrophoneStop() error {
|
||||||
|
if getAudioControlServiceFunc == nil {
|
||||||
|
return fmt.Errorf("audio control service not available")
|
||||||
|
}
|
||||||
|
service := getAudioControlServiceFunc()
|
||||||
|
if service == nil {
|
||||||
|
return fmt.Errorf("audio control service not initialized")
|
||||||
|
}
|
||||||
|
return service.StopMicrophone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCAudioStatus handles audio status RPC requests (read-only)
|
||||||
|
func RPCAudioStatus() (map[string]interface{}, error) {
|
||||||
|
if getAudioControlServiceFunc == nil {
|
||||||
|
return nil, fmt.Errorf("audio control service not available")
|
||||||
|
}
|
||||||
|
service := getAudioControlServiceFunc()
|
||||||
|
if service == nil {
|
||||||
|
return nil, fmt.Errorf("audio control service not initialized")
|
||||||
|
}
|
||||||
|
return service.GetAudioStatus(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCAudioQualityPresets handles audio quality presets RPC requests (read-only)
|
||||||
|
func RPCAudioQualityPresets() (map[string]any, error) {
|
||||||
|
if getAudioControlServiceFunc == nil || getAudioQualityFunc == nil {
|
||||||
|
return nil, fmt.Errorf("audio control service not available")
|
||||||
|
}
|
||||||
|
service := getAudioControlServiceFunc()
|
||||||
|
if service == nil {
|
||||||
|
return nil, fmt.Errorf("audio control service not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
presets := service.GetAudioQualityPresets()
|
||||||
|
current := getAudioQualityFunc()
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"presets": presets,
|
||||||
|
"current": current,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCMicrophoneStatus handles microphone status RPC requests (read-only)
|
||||||
|
func RPCMicrophoneStatus() (map[string]interface{}, error) {
|
||||||
|
if getAudioControlServiceFunc == nil {
|
||||||
|
return nil, fmt.Errorf("audio control service not available")
|
||||||
|
}
|
||||||
|
service := getAudioControlServiceFunc()
|
||||||
|
if service == nil {
|
||||||
|
return nil, fmt.Errorf("audio control service not initialized")
|
||||||
|
}
|
||||||
|
return service.GetMicrophoneStatus(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCMicrophoneReset handles microphone reset RPC requests
|
||||||
|
func RPCMicrophoneReset() error {
|
||||||
|
if getAudioControlServiceFunc == nil {
|
||||||
|
return fmt.Errorf("audio control service not available")
|
||||||
|
}
|
||||||
|
service := getAudioControlServiceFunc()
|
||||||
|
if service == nil {
|
||||||
|
return fmt.Errorf("audio control service not initialized")
|
||||||
|
}
|
||||||
|
return service.ResetMicrophone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCMicrophoneMute handles microphone mute RPC requests
|
||||||
|
func RPCMicrophoneMute(muted bool) error {
|
||||||
|
if getAudioControlServiceFunc == nil {
|
||||||
|
return fmt.Errorf("audio control service not available")
|
||||||
|
}
|
||||||
|
service := getAudioControlServiceFunc()
|
||||||
|
if service == nil {
|
||||||
|
return fmt.Errorf("audio control service not initialized")
|
||||||
|
}
|
||||||
|
return service.MuteMicrophone(muted)
|
||||||
|
}
|
||||||
76
jsonrpc.go
76
jsonrpc.go
|
|
@ -964,6 +964,21 @@ func rpcSetUsbDevices(usbDevices usbgadget.Devices) error {
|
||||||
logger.Info().Msg("audio input manager stopped")
|
logger.Info().Msg("audio input manager stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop global audio input supervisor if active
|
||||||
|
audioInputSupervisor := audio.GetAudioInputSupervisor()
|
||||||
|
if audioInputSupervisor != nil && audioInputSupervisor.IsRunning() {
|
||||||
|
logger.Info().Msg("stopping global audio input supervisor")
|
||||||
|
audioInputSupervisor.Stop()
|
||||||
|
// Wait for audio input supervisor to fully stop
|
||||||
|
for i := 0; i < 50; i++ { // Wait up to 5 seconds
|
||||||
|
if !audioInputSupervisor.IsRunning() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
logger.Info().Msg("global audio input supervisor stopped")
|
||||||
|
}
|
||||||
|
|
||||||
// Stop audio output supervisor
|
// Stop audio output supervisor
|
||||||
if audioSupervisor != nil && audioSupervisor.IsRunning() {
|
if audioSupervisor != nil && audioSupervisor.IsRunning() {
|
||||||
logger.Info().Msg("stopping audio output supervisor")
|
logger.Info().Msg("stopping audio output supervisor")
|
||||||
|
|
@ -1058,6 +1073,21 @@ func rpcSetUsbDeviceState(device string, enabled bool) error {
|
||||||
logger.Info().Msg("audio input manager stopped")
|
logger.Info().Msg("audio input manager stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop global audio input supervisor if active
|
||||||
|
audioInputSupervisor := audio.GetAudioInputSupervisor()
|
||||||
|
if audioInputSupervisor != nil && audioInputSupervisor.IsRunning() {
|
||||||
|
logger.Info().Msg("stopping global audio input supervisor")
|
||||||
|
audioInputSupervisor.Stop()
|
||||||
|
// Wait for audio input supervisor to fully stop
|
||||||
|
for i := 0; i < 50; i++ { // Wait up to 5 seconds
|
||||||
|
if !audioInputSupervisor.IsRunning() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
logger.Info().Msg("global audio input supervisor stopped")
|
||||||
|
}
|
||||||
|
|
||||||
// Stop audio output supervisor
|
// Stop audio output supervisor
|
||||||
if audioSupervisor != nil && audioSupervisor.IsRunning() {
|
if audioSupervisor != nil && audioSupervisor.IsRunning() {
|
||||||
logger.Info().Msg("stopping audio output supervisor")
|
logger.Info().Msg("stopping audio output supervisor")
|
||||||
|
|
@ -1339,6 +1369,43 @@ func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacro
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audio control RPC handlers - delegated to audio package
|
||||||
|
func rpcAudioMute(muted bool) error {
|
||||||
|
return audio.RPCAudioMute(muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcAudioQuality(quality int) (map[string]any, error) {
|
||||||
|
return audio.RPCAudioQuality(quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcMicrophoneStart() error {
|
||||||
|
return audio.RPCMicrophoneStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcMicrophoneStop() error {
|
||||||
|
return audio.RPCMicrophoneStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcAudioStatus() (map[string]interface{}, error) {
|
||||||
|
return audio.RPCAudioStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcAudioQualityPresets() (map[string]any, error) {
|
||||||
|
return audio.RPCAudioQualityPresets()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcMicrophoneStatus() (map[string]interface{}, error) {
|
||||||
|
return audio.RPCMicrophoneStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcMicrophoneReset() error {
|
||||||
|
return audio.RPCMicrophoneReset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcMicrophoneMute(muted bool) error {
|
||||||
|
return audio.RPCMicrophoneMute(muted)
|
||||||
|
}
|
||||||
|
|
||||||
var rpcHandlers = map[string]RPCHandler{
|
var rpcHandlers = map[string]RPCHandler{
|
||||||
"ping": {Func: rpcPing},
|
"ping": {Func: rpcPing},
|
||||||
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
||||||
|
|
@ -1388,6 +1455,15 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"isUpdatePending": {Func: rpcIsUpdatePending},
|
"isUpdatePending": {Func: rpcIsUpdatePending},
|
||||||
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
||||||
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
||||||
|
"audioMute": {Func: rpcAudioMute, Params: []string{"muted"}},
|
||||||
|
"audioQuality": {Func: rpcAudioQuality, Params: []string{"quality"}},
|
||||||
|
"audioStatus": {Func: rpcAudioStatus},
|
||||||
|
"audioQualityPresets": {Func: rpcAudioQualityPresets},
|
||||||
|
"microphoneStart": {Func: rpcMicrophoneStart},
|
||||||
|
"microphoneStop": {Func: rpcMicrophoneStop},
|
||||||
|
"microphoneStatus": {Func: rpcMicrophoneStatus},
|
||||||
|
"microphoneReset": {Func: rpcMicrophoneReset},
|
||||||
|
"microphoneMute": {Func: rpcMicrophoneMute, Params: []string{"muted"}},
|
||||||
"getUsbConfig": {Func: rpcGetUsbConfig},
|
"getUsbConfig": {Func: rpcGetUsbConfig},
|
||||||
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
||||||
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
||||||
|
|
|
||||||
36
main.go
36
main.go
|
|
@ -21,16 +21,6 @@ var (
|
||||||
audioSupervisor *audio.AudioOutputSupervisor
|
audioSupervisor *audio.AudioOutputSupervisor
|
||||||
)
|
)
|
||||||
|
|
||||||
// runAudioServer is now handled by audio.RunAudioOutputServer
|
|
||||||
// This function is kept for backward compatibility but delegates to the audio package
|
|
||||||
func runAudioServer() {
|
|
||||||
err := audio.RunAudioOutputServer()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error().Err(err).Msg("audio output server failed")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func startAudioSubprocess() error {
|
func startAudioSubprocess() error {
|
||||||
// Initialize validation cache for optimal performance
|
// Initialize validation cache for optimal performance
|
||||||
audio.InitValidationCache()
|
audio.InitValidationCache()
|
||||||
|
|
@ -47,14 +37,14 @@ func startAudioSubprocess() error {
|
||||||
audio.SetAudioInputSupervisor(audioInputSupervisor)
|
audio.SetAudioInputSupervisor(audioInputSupervisor)
|
||||||
|
|
||||||
// Set default OPUS configuration for audio input supervisor (low quality for single-core RV1106)
|
// Set default OPUS configuration for audio input supervisor (low quality for single-core RV1106)
|
||||||
config := audio.Config
|
audioConfig := audio.Config
|
||||||
audioInputSupervisor.SetOpusConfig(
|
audioInputSupervisor.SetOpusConfig(
|
||||||
config.AudioQualityLowInputBitrate*1000, // Convert kbps to bps
|
audioConfig.AudioQualityLowInputBitrate*1000, // Convert kbps to bps
|
||||||
config.AudioQualityLowOpusComplexity,
|
audioConfig.AudioQualityLowOpusComplexity,
|
||||||
config.AudioQualityLowOpusVBR,
|
audioConfig.AudioQualityLowOpusVBR,
|
||||||
config.AudioQualityLowOpusSignalType,
|
audioConfig.AudioQualityLowOpusSignalType,
|
||||||
config.AudioQualityLowOpusBandwidth,
|
audioConfig.AudioQualityLowOpusBandwidth,
|
||||||
config.AudioQualityLowOpusDTX,
|
audioConfig.AudioQualityLowOpusDTX,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Note: Audio input supervisor is NOT started here - it will be started on-demand
|
// Note: Audio input supervisor is NOT started here - it will be started on-demand
|
||||||
|
|
@ -110,6 +100,12 @@ func startAudioSubprocess() error {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Check if USB audio device is enabled before starting audio processes
|
||||||
|
if config.UsbDevices == nil || !config.UsbDevices.Audio {
|
||||||
|
logger.Info().Msg("USB audio device disabled - skipping audio supervisor startup")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Start the supervisor
|
// Start the supervisor
|
||||||
if err := audioSupervisor.Start(); err != nil {
|
if err := audioSupervisor.Start(); err != nil {
|
||||||
return fmt.Errorf("failed to start audio supervisor: %w", err)
|
return fmt.Errorf("failed to start audio supervisor: %w", err)
|
||||||
|
|
@ -137,7 +133,11 @@ func Main(audioServer bool, audioInputServer bool) {
|
||||||
|
|
||||||
// If running as audio server, only initialize audio processing
|
// If running as audio server, only initialize audio processing
|
||||||
if isAudioServer {
|
if isAudioServer {
|
||||||
runAudioServer()
|
err := audio.RunAudioOutputServer()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("audio output server failed")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ import { Fragment, useCallback, useRef } from "react";
|
||||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
|
import Container from "@components/Container";
|
||||||
import {
|
import {
|
||||||
useHidStore,
|
useHidStore,
|
||||||
useMountMediaStore,
|
useMountMediaStore,
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useUiStore,
|
useUiStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import Container from "@components/Container";
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import PasteModal from "@/components/popovers/PasteModal";
|
import PasteModal from "@/components/popovers/PasteModal";
|
||||||
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import { cva } from "@/cva.config";
|
||||||
|
|
||||||
import Card from "./Card";
|
import Card from "./Card";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface ComboboxOption {
|
export interface ComboboxOption {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { GridCard } from "@/components/Card";
|
||||||
|
|
||||||
import { cx } from "../cva.config";
|
import { cx } from "../cva.config";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
IconElm?: React.FC<{ className: string | undefined }>;
|
IconElm?: React.FC<{ className: string | undefined }>;
|
||||||
headline: string;
|
headline: string;
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,22 @@ import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/1
|
||||||
import { Button, Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
import { Button, Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||||
import { LuMonitorSmartphone } from "react-icons/lu";
|
import { LuMonitorSmartphone } from "react-icons/lu";
|
||||||
|
|
||||||
|
import USBStateStatus from "@components/USBStateStatus";
|
||||||
|
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
|
||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
|
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
|
||||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
import USBStateStatus from "@components/USBStateStatus";
|
|
||||||
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
|
|
||||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||||
|
|
||||||
import api from "../api";
|
|
||||||
import { isOnDevice } from "../main";
|
import { isOnDevice } from "../main";
|
||||||
|
import api from "../api";
|
||||||
|
|
||||||
import { LinkButton } from "./Button";
|
import { LinkButton } from "./Button";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
primaryLinks?: { title: string; to: string }[];
|
primaryLinks?: { title: string; to: string }[];
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { InputFieldWithLabel } from "./InputField";
|
import { InputFieldWithLabel } from "./InputField";
|
||||||
import { SelectMenuBasic } from "./SelectMenuBasic";
|
import { SelectMenuBasic } from "./SelectMenuBasic";
|
||||||
|
|
||||||
|
|
||||||
export interface JigglerConfig {
|
export interface JigglerConfig {
|
||||||
inactivity_limit_seconds: number;
|
inactivity_limit_seconds: number;
|
||||||
jitter_percentage: number;
|
jitter_percentage: number;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import React, { JSX } from "react";
|
import React, { JSX } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
import { cva } from "@/cva.config";
|
import { cva } from "@/cva.config";
|
||||||
|
|
||||||
import Card from "./Card";
|
import Card from "./Card";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type SelectMenuProps = Pick<
|
type SelectMenuProps = Pick<
|
||||||
JSX.IntrinsicElements["select"],
|
JSX.IntrinsicElements["select"],
|
||||||
"disabled" | "onChange" | "name" | "value"
|
"disabled" | "onChange" | "name" | "value"
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,13 @@ import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||||
|
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
|
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
|
||||||
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
|
||||||
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
|
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
|
||||||
|
|
||||||
// Terminal theme configuration
|
// Terminal theme configuration
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
|
||||||
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
|
|
||||||
import LoadingSpinner from "@components/LoadingSpinner";
|
import LoadingSpinner from "@components/LoadingSpinner";
|
||||||
import StatusCard from "@components/StatusCards";
|
import StatusCard from "@components/StatusCards";
|
||||||
|
import { cx } from "@/cva.config";
|
||||||
|
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
|
||||||
import { USBStates } from "@/hooks/stores";
|
import { USBStates } from "@/hooks/stores";
|
||||||
|
|
||||||
type StatusProps = Record<
|
type StatusProps = Record<
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||||
|
|
@ -6,6 +7,7 @@ import { Button } from "./Button";
|
||||||
import { GridCard } from "./Card";
|
import { GridCard } from "./Card";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
import LoadingSpinner from "./LoadingSpinner";
|
||||||
|
|
||||||
|
|
||||||
export default function UpdateInProgressStatusCard() {
|
export default function UpdateInProgressStatusCard() {
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Keyboard from "react-simple-keyboard";
|
import Keyboard from "react-simple-keyboard";
|
||||||
import { LuKeyboard } from "react-icons/lu";
|
import { LuKeyboard } from "react-icons/lu";
|
||||||
|
|
||||||
import Card from "@components/Card";
|
|
||||||
// eslint-disable-next-line import/order
|
|
||||||
import { Button, LinkButton } from "@components/Button";
|
|
||||||
|
|
||||||
import "react-simple-keyboard/build/css/index.css";
|
import "react-simple-keyboard/build/css/index.css";
|
||||||
|
|
||||||
|
import Card from "@components/Card";
|
||||||
|
import { Button, LinkButton } from "@components/Button";
|
||||||
import DetachIconRaw from "@/assets/detach-icon.svg";
|
import DetachIconRaw from "@/assets/detach-icon.svg";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { useHidStore, useUiStore } from "@/hooks/stores";
|
import { useHidStore, useUiStore } from "@/hooks/stores";
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { useResizeObserver } from "usehooks-ts";
|
||||||
|
|
||||||
import VirtualKeyboard from "@components/VirtualKeyboard";
|
import VirtualKeyboard from "@components/VirtualKeyboard";
|
||||||
import Actionbar from "@components/ActionBar";
|
import Actionbar from "@components/ActionBar";
|
||||||
import MacroBar from "@/components/MacroBar";
|
|
||||||
import InfoBar from "@components/InfoBar";
|
import InfoBar from "@components/InfoBar";
|
||||||
|
import MacroBar from "@/components/MacroBar";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
PointerLockBar,
|
PointerLockBar,
|
||||||
} from "./VideoOverlay";
|
} from "./VideoOverlay";
|
||||||
|
|
||||||
|
|
||||||
// Type for microphone error
|
// Type for microphone error
|
||||||
interface MicrophoneError {
|
interface MicrophoneError {
|
||||||
type: 'permission' | 'device' | 'network' | 'unknown';
|
type: 'permission' | 'device' | 'network' | 'unknown';
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import LoadingSpinner from "@/components/LoadingSpinner";
|
||||||
|
|
||||||
import { JsonRpcResponse, useJsonRpc } from "../../hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "../../hooks/useJsonRpc";
|
||||||
|
|
||||||
|
|
||||||
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
|
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
|
||||||
|
|
||||||
interface ATXState {
|
interface ATXState {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import { useCallback, useEffect, useState } from "react";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
|
||||||
import notifications from "@/notifications";
|
|
||||||
import FieldLabel from "@components/FieldLabel";
|
import FieldLabel from "@components/FieldLabel";
|
||||||
import LoadingSpinner from "@components/LoadingSpinner";
|
import LoadingSpinner from "@components/LoadingSpinner";
|
||||||
import {SelectMenuBasic} from "@components/SelectMenuBasic";
|
import {SelectMenuBasic} from "@components/SelectMenuBasic";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
|
||||||
interface DCPowerState {
|
interface DCPowerState {
|
||||||
isOn: boolean;
|
isOn: boolean;
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ import { useEffect, useState } from "react";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { useUiStore } from "@/hooks/stores";
|
import { useUiStore } from "@/hooks/stores";
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
|
||||||
|
|
||||||
interface SerialSettings {
|
interface SerialSettings {
|
||||||
baudRate: string;
|
baudRate: string;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import { Button } from "@components/Button";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { useAudioDevices } from "@/hooks/useAudioDevices";
|
import { useAudioDevices } from "@/hooks/useAudioDevices";
|
||||||
import { useAudioEvents } from "@/hooks/useAudioEvents";
|
import { useAudioEvents } from "@/hooks/useAudioEvents";
|
||||||
import api from "@/api";
|
import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc";
|
||||||
|
import { useRTCStore } from "@/hooks/stores";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import audioQualityService from "@/services/audioQualityService";
|
import audioQualityService from "@/services/audioQualityService";
|
||||||
|
|
||||||
|
|
@ -38,9 +39,6 @@ interface AudioConfig {
|
||||||
FrameSize: string;
|
FrameSize: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quality labels will be managed by the audio quality service
|
|
||||||
const getQualityLabels = () => audioQualityService.getQualityLabels();
|
|
||||||
|
|
||||||
interface AudioControlPopoverProps {
|
interface AudioControlPopoverProps {
|
||||||
microphone: MicrophoneHookReturn;
|
microphone: MicrophoneHookReturn;
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +62,17 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP
|
||||||
isConnected: wsConnected
|
isConnected: wsConnected
|
||||||
} = useAudioEvents();
|
} = useAudioEvents();
|
||||||
|
|
||||||
|
// RPC for device communication (works both locally and via cloud)
|
||||||
|
const { rpcDataChannel } = useRTCStore();
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
|
// Initialize audio quality service with RPC for cloud compatibility
|
||||||
|
useEffect(() => {
|
||||||
|
if (send) {
|
||||||
|
audioQualityService.setRpcSend(send);
|
||||||
|
}
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
// WebSocket-only implementation - no fallback polling
|
// WebSocket-only implementation - no fallback polling
|
||||||
|
|
||||||
// Microphone state from props (keeping hook for legacy device operations)
|
// Microphone state from props (keeping hook for legacy device operations)
|
||||||
|
|
@ -82,9 +91,6 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP
|
||||||
const isMuted = audioMuted ?? false;
|
const isMuted = audioMuted ?? false;
|
||||||
const isConnected = wsConnected;
|
const isConnected = wsConnected;
|
||||||
|
|
||||||
// Note: We now use hook state instead of WebSocket state for microphone Enable/Disable
|
|
||||||
// const isMicrophoneActiveFromWS = microphoneState?.running ?? false;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Audio devices
|
// Audio devices
|
||||||
|
|
@ -146,21 +152,22 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isMuted) {
|
// Use RPC for device communication - works for both local and cloud
|
||||||
// Unmute: Start audio output process and notify backend
|
if (rpcDataChannel?.readyState !== "open") {
|
||||||
const resp = await api.POST("/audio/mute", { muted: false });
|
throw new Error("Device connection not available");
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error(`Failed to unmute audio: ${resp.status}`);
|
|
||||||
}
|
}
|
||||||
// WebSocket will handle the state update automatically
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
send("audioMute", { muted: !isMuted }, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
reject(new Error(resp.error.message));
|
||||||
} else {
|
} else {
|
||||||
// Mute: Stop audio output process and notify backend
|
resolve();
|
||||||
const resp = await api.POST("/audio/mute", { muted: true });
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error(`Failed to mute audio: ${resp.status}`);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// WebSocket will handle the state update automatically
|
// WebSocket will handle the state update automatically
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to toggle audio mute";
|
const errorMessage = error instanceof Error ? error.message : "Failed to toggle audio mute";
|
||||||
notifications.error(errorMessage);
|
notifications.error(errorMessage);
|
||||||
|
|
@ -172,13 +179,27 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP
|
||||||
const handleQualityChange = async (quality: number) => {
|
const handleQualityChange = async (quality: number) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const resp = await api.POST("/audio/quality", { quality });
|
// Use RPC for device communication - works for both local and cloud
|
||||||
if (resp.ok) {
|
if (rpcDataChannel?.readyState !== "open") {
|
||||||
const data = await resp.json();
|
throw new Error("Device connection not available");
|
||||||
setCurrentConfig(data.config);
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Failed to change audio quality
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
send("audioQuality", { quality }, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
reject(new Error(resp.error.message));
|
||||||
|
} else {
|
||||||
|
// Update local state with response
|
||||||
|
if ("result" in resp && resp.result && typeof resp.result === 'object' && 'config' in resp.result) {
|
||||||
|
setCurrentConfig(resp.result.config as AudioConfig);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Failed to change audio quality";
|
||||||
|
notifications.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -196,17 +217,44 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Use RPC for device communication - works for both local and cloud
|
||||||
|
if (rpcDataChannel?.readyState !== "open") {
|
||||||
|
throw new Error("Device connection not available");
|
||||||
|
}
|
||||||
|
|
||||||
if (isMicrophoneActiveFromHook) {
|
if (isMicrophoneActiveFromHook) {
|
||||||
// Disable: Stop microphone subprocess AND remove WebRTC tracks
|
// Disable: Stop microphone subprocess via RPC AND remove WebRTC tracks locally
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
send("microphoneStop", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
reject(new Error(resp.error.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also stop local WebRTC stream
|
||||||
const result = await stopMicrophone();
|
const result = await stopMicrophone();
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error?.message || "Failed to stop microphone");
|
console.warn("Local microphone stop failed:", result.error?.message);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Enable: Start microphone subprocess AND add WebRTC tracks
|
// Enable: Start microphone subprocess via RPC AND add WebRTC tracks locally
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
send("microphoneStart", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
reject(new Error(resp.error.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also start local WebRTC stream
|
||||||
const result = await startMicrophone();
|
const result = await startMicrophone();
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error?.message || "Failed to start microphone");
|
throw new Error(result.error?.message || "Failed to start local microphone");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -409,7 +457,7 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{Object.entries(getQualityLabels()).map(([quality, label]) => (
|
{Object.entries(audioQualityService.getQualityLabels()).map(([quality, label]) => (
|
||||||
<button
|
<button
|
||||||
key={quality}
|
key={quality}
|
||||||
onClick={() => handleQualityChange(parseInt(quality))}
|
onClick={() => handleQualityChange(parseInt(quality))}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
|
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
|
||||||
|
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
|
||||||
import Card, { GridCard } from "@components/Card";
|
import Card, { GridCard } from "@components/Card";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
|
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
|
||||||
import { DCPowerControl } from "@components/extensions/DCPowerControl";
|
import { DCPowerControl } from "@components/extensions/DCPowerControl";
|
||||||
import { SerialConsole } from "@components/extensions/SerialConsole";
|
import { SerialConsole } from "@components/extensions/SerialConsole";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
interface Extension {
|
interface Extension {
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ import { useLocation } from "react-router";
|
||||||
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import Card, { GridCard } from "@components/Card";
|
import Card, { GridCard } from "@components/Card";
|
||||||
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { formatters } from "@/utils";
|
import { formatters } from "@/utils";
|
||||||
import { RemoteVirtualMediaState, useMountMediaStore } from "@/hooks/stores";
|
import { RemoteVirtualMediaState, useMountMediaStore } from "@/hooks/stores";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,17 @@ import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { LuCornerDownLeft } from "react-icons/lu";
|
import { LuCornerDownLeft } from "react-icons/lu";
|
||||||
|
|
||||||
|
import { Button } from "@components/Button";
|
||||||
|
import { GridCard } from "@components/Card";
|
||||||
|
import { InputFieldWithLabel } from "@components/InputField";
|
||||||
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
|
import { TextAreaWithLabel } from "@components/TextArea";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import useKeyboard, { type MacroStep } from "@/hooks/useKeyboard";
|
import useKeyboard, { type MacroStep } from "@/hooks/useKeyboard";
|
||||||
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import { GridCard } from "@components/Card";
|
|
||||||
import { InputFieldWithLabel } from "@components/InputField";
|
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
|
||||||
import { TextAreaWithLabel } from "@components/TextArea";
|
|
||||||
|
|
||||||
// uint32 max value / 4
|
// uint32 max value / 4
|
||||||
const pasteMaxLength = 1073741824;
|
const pasteMaxLength = 1073741824;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import EmptyStateCard from "./EmptyStateCard";
|
||||||
import DeviceList, { StoredDevice } from "./DeviceList";
|
import DeviceList, { StoredDevice } from "./DeviceList";
|
||||||
import AddDeviceForm from "./AddDeviceForm";
|
import AddDeviceForm from "./AddDeviceForm";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function WakeOnLanModal() {
|
export default function WakeOnLanModal() {
|
||||||
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
|
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { useInterval } from "usehooks-ts";
|
import { useInterval } from "usehooks-ts";
|
||||||
|
|
||||||
|
|
||||||
import SidebarHeader from "@/components/SidebarHeader";
|
import SidebarHeader from "@/components/SidebarHeader";
|
||||||
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
||||||
import { someIterable } from "@/utils";
|
import { someIterable } from "@/utils";
|
||||||
|
|
||||||
import { createChartArray, Metric } from "../Metric";
|
|
||||||
import { SettingsSectionHeader } from "../SettingsSectionHeader";
|
import { SettingsSectionHeader } from "../SettingsSectionHeader";
|
||||||
|
import { createChartArray, Metric } from "../Metric";
|
||||||
|
|
||||||
|
|
||||||
export default function ConnectionStatsSidebar() {
|
export default function ConnectionStatsSidebar() {
|
||||||
const { sidebarView, setSidebarView } = useUiStore();
|
const { sidebarView, setSidebarView } = useUiStore();
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ import {
|
||||||
MAX_KEYS_PER_STEP,
|
MAX_KEYS_PER_STEP,
|
||||||
} from "@/constants/macros";
|
} from "@/constants/macros";
|
||||||
|
|
||||||
import { devWarn } from '../utils/debug';
|
|
||||||
|
|
||||||
// Define the JsonRpc types for better type checking
|
// Define the JsonRpc types for better type checking
|
||||||
interface JsonRpcResponse {
|
interface JsonRpcResponse {
|
||||||
jsonrpc: string;
|
jsonrpc: string;
|
||||||
|
|
@ -780,7 +778,7 @@ export const useNetworkStateStore = create<NetworkState>((set, get) => ({
|
||||||
setDhcpLeaseExpiry: (expiry: Date) => {
|
setDhcpLeaseExpiry: (expiry: Date) => {
|
||||||
const lease = get().dhcp_lease;
|
const lease = get().dhcp_lease;
|
||||||
if (!lease) {
|
if (!lease) {
|
||||||
devWarn("No lease found");
|
console.warn("No lease found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -843,7 +841,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
|
|
||||||
const { sendFn } = get();
|
const { sendFn } = get();
|
||||||
if (!sendFn) {
|
if (!sendFn) {
|
||||||
// console.warn("JSON-RPC send function not available.");
|
console.warn("JSON-RPC send function not available.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -853,7 +851,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
sendFn("getKeyboardMacros", {}, (response: JsonRpcResponse) => {
|
sendFn("getKeyboardMacros", {}, (response: JsonRpcResponse) => {
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
// console.error("Error loading macros:", response.error);
|
console.error("Error loading macros:", response.error);
|
||||||
reject(new Error(response.error.message));
|
reject(new Error(response.error.message));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -877,8 +875,8 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
// console.error("Failed to load macros:", _error);
|
console.error("Failed to load macros:", error);
|
||||||
} finally {
|
} finally {
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
}
|
}
|
||||||
|
|
@ -887,20 +885,20 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
saveMacros: async (macros: KeySequence[]) => {
|
saveMacros: async (macros: KeySequence[]) => {
|
||||||
const { sendFn } = get();
|
const { sendFn } = get();
|
||||||
if (!sendFn) {
|
if (!sendFn) {
|
||||||
// console.warn("JSON-RPC send function not available.");
|
console.warn("JSON-RPC send function not available.");
|
||||||
throw new Error("JSON-RPC send function not available");
|
throw new Error("JSON-RPC send function not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (macros.length > MAX_TOTAL_MACROS) {
|
if (macros.length > MAX_TOTAL_MACROS) {
|
||||||
// console.error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`);
|
console.error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`);
|
||||||
throw new Error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`);
|
throw new Error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const macro of macros) {
|
for (const macro of macros) {
|
||||||
if (macro.steps.length > MAX_STEPS_PER_MACRO) {
|
if (macro.steps.length > MAX_STEPS_PER_MACRO) {
|
||||||
// console.error(
|
console.error(
|
||||||
// `Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
|
`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
|
||||||
// );
|
);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
|
`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
|
||||||
);
|
);
|
||||||
|
|
@ -909,9 +907,9 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
for (let i = 0; i < macro.steps.length; i++) {
|
for (let i = 0; i < macro.steps.length; i++) {
|
||||||
const step = macro.steps[i];
|
const step = macro.steps[i];
|
||||||
if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) {
|
if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) {
|
||||||
// console.error(
|
console.error(
|
||||||
// `Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
|
`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
|
||||||
// );
|
);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
|
`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
|
||||||
);
|
);
|
||||||
|
|
@ -938,7 +936,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
// console.error("Error saving macros:", response.error);
|
console.error("Error saving macros:", response.error);
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
typeof response.error.data === "string"
|
typeof response.error.data === "string"
|
||||||
? response.error.data
|
? response.error.data
|
||||||
|
|
@ -948,6 +946,9 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
|
|
||||||
// Only update the store if the request was successful
|
// Only update the store if the request was successful
|
||||||
set({ macros: macrosWithSortOrder });
|
set({ macros: macrosWithSortOrder });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save macros:", error);
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import useWebSocket, { ReadyState } from 'react-use-websocket';
|
||||||
import { devError, devWarn } from '../utils/debug';
|
import { devError, devWarn } from '../utils/debug';
|
||||||
import { NETWORK_CONFIG } from '../config/constants';
|
import { NETWORK_CONFIG } from '../config/constants';
|
||||||
|
|
||||||
|
import { JsonRpcResponse, useJsonRpc } from './useJsonRpc';
|
||||||
|
import { useRTCStore } from './stores';
|
||||||
|
|
||||||
// Audio event types matching the backend
|
// Audio event types matching the backend
|
||||||
export type AudioEventType =
|
export type AudioEventType =
|
||||||
| 'audio-mute-changed'
|
| 'audio-mute-changed'
|
||||||
|
|
@ -63,18 +66,34 @@ export function useAudioEvents(onAudioDeviceChanged?: (data: AudioDeviceChangedD
|
||||||
const [audioMuted, setAudioMuted] = useState<boolean | null>(null);
|
const [audioMuted, setAudioMuted] = useState<boolean | null>(null);
|
||||||
const [microphoneState, setMicrophoneState] = useState<MicrophoneStateData | null>(null);
|
const [microphoneState, setMicrophoneState] = useState<MicrophoneStateData | null>(null);
|
||||||
|
|
||||||
// Fetch initial audio status
|
// Get RTC store and JSON RPC functionality
|
||||||
|
const { rpcDataChannel } = useRTCStore();
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
|
// Fetch initial audio status using RPC for cloud compatibility
|
||||||
const fetchInitialAudioStatus = useCallback(async () => {
|
const fetchInitialAudioStatus = useCallback(async () => {
|
||||||
|
// Early return if RPC data channel is not open
|
||||||
|
if (rpcDataChannel?.readyState !== "open") {
|
||||||
|
devWarn('RPC connection not available for initial audio status, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/audio/status');
|
await new Promise<void>((resolve) => {
|
||||||
if (response.ok) {
|
send("audioStatus", {}, (resp: JsonRpcResponse) => {
|
||||||
const data = await response.json();
|
if ("error" in resp) {
|
||||||
|
devError('RPC audioStatus failed:', resp.error);
|
||||||
|
} else if ("result" in resp) {
|
||||||
|
const data = resp.result as { muted: boolean };
|
||||||
setAudioMuted(data.muted);
|
setAudioMuted(data.muted);
|
||||||
}
|
}
|
||||||
|
resolve(); // Continue regardless of result
|
||||||
|
});
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
devError('Failed to fetch initial audio status:', error);
|
devError('Failed to fetch initial audio status via RPC:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [rpcDataChannel?.readyState, send]);
|
||||||
|
|
||||||
// Local subscription state
|
// Local subscription state
|
||||||
const [isLocallySubscribed, setIsLocallySubscribed] = useState(false);
|
const [isLocallySubscribed, setIsLocallySubscribed] = useState(false);
|
||||||
|
|
@ -253,10 +272,13 @@ export function useAudioEvents(onAudioDeviceChanged?: (data: AudioDeviceChangedD
|
||||||
}
|
}
|
||||||
}, [readyState]);
|
}, [readyState]);
|
||||||
|
|
||||||
// Fetch initial audio status on component mount
|
// Fetch initial audio status on component mount - but only when RPC is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only fetch when RPC data channel is open and ready
|
||||||
|
if (rpcDataChannel?.readyState === "open") {
|
||||||
fetchInitialAudioStatus();
|
fetchInitialAudioStatus();
|
||||||
}, [fetchInitialAudioStatus]);
|
}
|
||||||
|
}, [fetchInitialAudioStatus, rpcDataChannel?.readyState]);
|
||||||
|
|
||||||
// Cleanup on component unmount
|
// Cleanup on component unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import {
|
||||||
unmarshalHidRpcMessage,
|
unmarshalHidRpcMessage,
|
||||||
} from "./hidRpc";
|
} from "./hidRpc";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const KEEPALIVE_MESSAGE = new KeypressKeepAliveMessage();
|
const KEEPALIVE_MESSAGE = new KeypressKeepAliveMessage();
|
||||||
|
|
||||||
interface sendMessageParams {
|
interface sendMessageParams {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useRTCStore, useSettingsStore } from "@/hooks/stores";
|
import { useRTCStore, useSettingsStore } from "@/hooks/stores";
|
||||||
import api from "@/api";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { devLog, devInfo, devWarn, devError, devOnly } from "@/utils/debug";
|
import { devLog, devInfo, devWarn, devError, devOnly } from "@/utils/debug";
|
||||||
import { AUDIO_CONFIG } from "@/config/constants";
|
import { AUDIO_CONFIG } from "@/config/constants";
|
||||||
|
|
||||||
|
|
@ -21,9 +21,29 @@ export function useMicrophone() {
|
||||||
setMicrophoneActive,
|
setMicrophoneActive,
|
||||||
isMicrophoneMuted,
|
isMicrophoneMuted,
|
||||||
setMicrophoneMuted,
|
setMicrophoneMuted,
|
||||||
|
rpcDataChannel,
|
||||||
} = useRTCStore();
|
} = useRTCStore();
|
||||||
|
|
||||||
const { microphoneWasEnabled, setMicrophoneWasEnabled } = useSettingsStore();
|
const { microphoneWasEnabled, setMicrophoneWasEnabled } = useSettingsStore();
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
|
// RPC helper functions to replace HTTP API calls
|
||||||
|
const rpcMicrophoneStart = useCallback((): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (rpcDataChannel?.readyState !== "open") {
|
||||||
|
reject(new Error("Device connection not available"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send("microphoneStart", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
reject(new Error(resp.error.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [rpcDataChannel?.readyState, send]);
|
||||||
|
|
||||||
const microphoneStreamRef = useRef<MediaStream | null>(null);
|
const microphoneStreamRef = useRef<MediaStream | null>(null);
|
||||||
|
|
||||||
|
|
@ -60,8 +80,6 @@ export function useMicrophone() {
|
||||||
|
|
||||||
// Cleanup function to stop microphone stream
|
// Cleanup function to stop microphone stream
|
||||||
const stopMicrophoneStream = useCallback(async () => {
|
const stopMicrophoneStream = useCallback(async () => {
|
||||||
// Cleaning up microphone stream
|
|
||||||
|
|
||||||
if (microphoneStreamRef.current) {
|
if (microphoneStreamRef.current) {
|
||||||
microphoneStreamRef.current.getTracks().forEach((track: MediaStreamTrack) => {
|
microphoneStreamRef.current.getTracks().forEach((track: MediaStreamTrack) => {
|
||||||
track.stop();
|
track.stop();
|
||||||
|
|
@ -95,21 +113,29 @@ export function useMicrophone() {
|
||||||
// Debounce sync calls to prevent race conditions
|
// Debounce sync calls to prevent race conditions
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastSyncRef.current < AUDIO_CONFIG.SYNC_DEBOUNCE_MS) {
|
if (now - lastSyncRef.current < AUDIO_CONFIG.SYNC_DEBOUNCE_MS) {
|
||||||
devLog("Skipping sync - too frequent");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastSyncRef.current = now;
|
lastSyncRef.current = now;
|
||||||
|
|
||||||
// Don't sync if we're in the middle of starting the microphone
|
// Don't sync if we're in the middle of starting the microphone
|
||||||
if (isStartingRef.current) {
|
if (isStartingRef.current) {
|
||||||
devLog("Skipping sync - microphone is starting");
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Early return if RPC data channel is not ready
|
||||||
|
if (rpcDataChannel?.readyState !== "open") {
|
||||||
|
devWarn("RPC connection not available for microphone sync, skipping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.GET("/microphone/status", {});
|
await new Promise<void>((resolve, reject) => {
|
||||||
if (response.ok) {
|
send("microphoneStatus", {}, (resp: JsonRpcResponse) => {
|
||||||
const data = await response.json();
|
if ("error" in resp) {
|
||||||
|
devError("RPC microphone status failed:", resp.error);
|
||||||
|
reject(new Error(resp.error.message));
|
||||||
|
} else if ("result" in resp) {
|
||||||
|
const data = resp.result as { running: boolean };
|
||||||
const backendRunning = data.running;
|
const backendRunning = data.running;
|
||||||
|
|
||||||
// Only sync if there's a significant state difference and we're not in a transition
|
// Only sync if there's a significant state difference and we're not in a transition
|
||||||
|
|
@ -127,16 +153,21 @@ export function useMicrophone() {
|
||||||
setMicrophoneActive(false);
|
setMicrophoneActive(false);
|
||||||
// Only clean up stream if we actually have one
|
// Only clean up stream if we actually have one
|
||||||
if (microphoneStreamRef.current) {
|
if (microphoneStreamRef.current) {
|
||||||
devLog("Cleaning up orphaned stream");
|
stopMicrophoneStream();
|
||||||
await stopMicrophoneStream();
|
}
|
||||||
}
|
setMicrophoneMuted(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error("Invalid response"));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
devWarn("Failed to sync microphone state:", error);
|
devError("Error syncing microphone state:", error);
|
||||||
}
|
}
|
||||||
}, [isMicrophoneActive, setMicrophoneActive, stopMicrophoneStream]);
|
}, [isMicrophoneActive, setMicrophoneActive, setMicrophoneMuted, stopMicrophoneStream, rpcDataChannel?.readyState, send]);
|
||||||
|
|
||||||
// Start microphone stream
|
// Start microphone stream
|
||||||
const startMicrophone = useCallback(async (deviceId?: string): Promise<{ success: boolean; error?: MicrophoneError }> => {
|
const startMicrophone = useCallback(async (deviceId?: string): Promise<{ success: boolean; error?: MicrophoneError }> => {
|
||||||
|
|
@ -164,34 +195,17 @@ export function useMicrophone() {
|
||||||
audioConstraints.deviceId = { exact: deviceId };
|
audioConstraints.deviceId = { exact: deviceId };
|
||||||
}
|
}
|
||||||
|
|
||||||
devLog("Requesting microphone with constraints:", audioConstraints);
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: audioConstraints
|
audio: audioConstraints
|
||||||
});
|
});
|
||||||
|
|
||||||
// Microphone stream created successfully
|
|
||||||
|
|
||||||
// Store the stream in both ref and store
|
// Store the stream in both ref and store
|
||||||
microphoneStreamRef.current = stream;
|
microphoneStreamRef.current = stream;
|
||||||
setMicrophoneStream(stream);
|
setMicrophoneStream(stream);
|
||||||
|
|
||||||
// Verify the stream was stored correctly
|
|
||||||
devLog("Stream storage verification:", {
|
|
||||||
refSet: !!microphoneStreamRef.current,
|
|
||||||
refId: microphoneStreamRef.current?.id,
|
|
||||||
storeWillBeSet: true // Store update is async
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add audio track to peer connection if available
|
// Add audio track to peer connection if available
|
||||||
devLog("Peer connection state:", peerConnection ? {
|
|
||||||
connectionState: peerConnection.connectionState,
|
|
||||||
iceConnectionState: peerConnection.iceConnectionState,
|
|
||||||
signalingState: peerConnection.signalingState
|
|
||||||
} : "No peer connection");
|
|
||||||
|
|
||||||
if (peerConnection && stream.getAudioTracks().length > 0) {
|
if (peerConnection && stream.getAudioTracks().length > 0) {
|
||||||
const audioTrack = stream.getAudioTracks()[0];
|
const audioTrack = stream.getAudioTracks()[0];
|
||||||
devLog("Starting microphone with audio track:", audioTrack.id, "kind:", audioTrack.kind);
|
|
||||||
|
|
||||||
// Find the audio transceiver (should already exist with sendrecv direction)
|
// Find the audio transceiver (should already exist with sendrecv direction)
|
||||||
const transceivers = peerConnection.getTransceivers();
|
const transceivers = peerConnection.getTransceivers();
|
||||||
|
|
@ -215,64 +229,28 @@ export function useMicrophone() {
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
devLog("Found audio transceiver:", audioTransceiver ? {
|
|
||||||
direction: audioTransceiver.direction,
|
|
||||||
mid: audioTransceiver.mid,
|
|
||||||
senderTrack: audioTransceiver.sender.track?.kind,
|
|
||||||
receiverTrack: audioTransceiver.receiver.track?.kind
|
|
||||||
} : null);
|
|
||||||
|
|
||||||
let sender: RTCRtpSender;
|
let sender: RTCRtpSender;
|
||||||
if (audioTransceiver && audioTransceiver.sender) {
|
if (audioTransceiver && audioTransceiver.sender) {
|
||||||
// Use the existing audio transceiver's sender
|
// Use the existing audio transceiver's sender
|
||||||
await audioTransceiver.sender.replaceTrack(audioTrack);
|
await audioTransceiver.sender.replaceTrack(audioTrack);
|
||||||
sender = audioTransceiver.sender;
|
sender = audioTransceiver.sender;
|
||||||
devLog("Replaced audio track on existing transceiver");
|
|
||||||
|
|
||||||
// Verify the track was set correctly
|
|
||||||
devLog("Transceiver after track replacement:", {
|
|
||||||
direction: audioTransceiver.direction,
|
|
||||||
senderTrack: audioTransceiver.sender.track?.id,
|
|
||||||
senderTrackKind: audioTransceiver.sender.track?.kind,
|
|
||||||
senderTrackEnabled: audioTransceiver.sender.track?.enabled,
|
|
||||||
senderTrackReadyState: audioTransceiver.sender.track?.readyState
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback: add new track if no transceiver found
|
// Fallback: add new track if no transceiver found
|
||||||
sender = peerConnection.addTrack(audioTrack, stream);
|
sender = peerConnection.addTrack(audioTrack, stream);
|
||||||
devLog("Added new audio track to peer connection");
|
|
||||||
|
|
||||||
// Find the transceiver that was created for this track
|
|
||||||
const newTransceiver = peerConnection.getTransceivers().find(t => t.sender === sender);
|
|
||||||
devLog("New transceiver created:", newTransceiver ? {
|
|
||||||
direction: newTransceiver.direction,
|
|
||||||
senderTrack: newTransceiver.sender.track?.id,
|
|
||||||
senderTrackKind: newTransceiver.sender.track?.kind
|
|
||||||
} : "Not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setMicrophoneSender(sender);
|
setMicrophoneSender(sender);
|
||||||
devLog("Microphone sender set:", {
|
|
||||||
senderId: sender,
|
|
||||||
track: sender.track?.id,
|
|
||||||
trackKind: sender.track?.kind,
|
|
||||||
trackEnabled: sender.track?.enabled,
|
|
||||||
trackReadyState: sender.track?.readyState
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check sender stats to verify audio is being transmitted
|
// Check sender stats to verify audio is being transmitted
|
||||||
devOnly(() => {
|
devOnly(() => {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const stats = await sender.getStats();
|
const stats = await sender.getStats();
|
||||||
devLog("Sender stats after 2 seconds:");
|
stats.forEach((report) => {
|
||||||
stats.forEach((report, id) => {
|
|
||||||
if (report.type === 'outbound-rtp' && report.kind === 'audio') {
|
if (report.type === 'outbound-rtp' && report.kind === 'audio') {
|
||||||
devLog("Outbound audio RTP stats:", {
|
devLog("Audio RTP stats:", {
|
||||||
id,
|
|
||||||
packetsSent: report.packetsSent,
|
packetsSent: report.packetsSent,
|
||||||
bytesSent: report.bytesSent,
|
bytesSent: report.bytesSent
|
||||||
timestamp: report.timestamp
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -284,82 +262,53 @@ export function useMicrophone() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify backend that microphone is started
|
// Notify backend that microphone is started
|
||||||
devLog("Notifying backend about microphone start...");
|
|
||||||
|
|
||||||
// Retry logic for backend failures
|
// Retry logic for backend failures
|
||||||
let backendSuccess = false;
|
let backendSuccess = false;
|
||||||
let lastError: Error | string | null = null;
|
let lastError: Error | string | null = null;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
try {
|
|
||||||
// If this is a retry, first try to reset the backend microphone state
|
// If this is a retry, first try to reset the backend microphone state
|
||||||
if (attempt > 1) {
|
if (attempt > 1) {
|
||||||
devLog(`Backend start attempt ${attempt}, first trying to reset backend state...`);
|
|
||||||
try {
|
try {
|
||||||
// Try the new reset endpoint first
|
// Use RPC for reset (cloud-compatible)
|
||||||
const resetResp = await api.POST("/microphone/reset", {});
|
if (rpcDataChannel?.readyState === "open") {
|
||||||
if (resetResp.ok) {
|
await new Promise<void>((resolve) => {
|
||||||
devLog("Backend reset successful");
|
send("microphoneReset", {}, (resp: JsonRpcResponse) => {
|
||||||
} else {
|
if ("error" in resp) {
|
||||||
// Fallback to stop
|
devWarn("RPC microphone reset failed:", resp.error);
|
||||||
await api.POST("/microphone/stop", {});
|
// Try stop as fallback
|
||||||
|
send("microphoneStop", {}, (stopResp: JsonRpcResponse) => {
|
||||||
|
if ("error" in stopResp) {
|
||||||
|
devWarn("RPC microphone stop also failed:", stopResp.error);
|
||||||
}
|
}
|
||||||
|
resolve(); // Continue even if both fail
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
// Wait a bit for the backend to reset
|
// Wait a bit for the backend to reset
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
} else {
|
||||||
|
devWarn("RPC connection not available for reset");
|
||||||
|
}
|
||||||
} catch (resetError) {
|
} catch (resetError) {
|
||||||
devWarn("Failed to reset backend state:", resetError);
|
devWarn("Failed to reset backend state:", resetError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendResp = await api.POST("/microphone/start", {});
|
|
||||||
devLog(`Backend response status (attempt ${attempt}):`, backendResp.status, "ok:", backendResp.ok);
|
|
||||||
|
|
||||||
if (!backendResp.ok) {
|
|
||||||
lastError = `Backend returned status ${backendResp.status}`;
|
|
||||||
devError(`Backend microphone start failed with status: ${backendResp.status} (attempt ${attempt})`);
|
|
||||||
|
|
||||||
// For 500 errors, try again after a short delay
|
|
||||||
if (backendResp.status === 500 && attempt < 3) {
|
|
||||||
devLog(`Retrying backend start in 500ms (attempt ${attempt + 1}/3)...`);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Success!
|
|
||||||
const responseData = await backendResp.json();
|
|
||||||
devLog("Backend response data:", responseData);
|
|
||||||
if (responseData.status === "already running") {
|
|
||||||
devInfo("Backend microphone was already running");
|
|
||||||
|
|
||||||
// If we're on the first attempt and backend says "already running",
|
|
||||||
// but frontend thinks it's not active, this might be a stuck state
|
|
||||||
if (attempt === 1 && !isMicrophoneActive) {
|
|
||||||
devWarn("Backend reports 'already running' but frontend is not active - possible stuck state");
|
|
||||||
devLog("Attempting to reset backend state and retry...");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resetResp = await api.POST("/microphone/reset", {});
|
await rpcMicrophoneStart();
|
||||||
if (resetResp.ok) {
|
|
||||||
devLog("Backend reset successful, retrying start...");
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
|
||||||
continue; // Retry the start
|
|
||||||
}
|
|
||||||
} catch (resetError) {
|
|
||||||
devWarn("Failed to reset stuck backend state:", resetError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
devLog("Backend microphone start successful");
|
|
||||||
backendSuccess = true;
|
backendSuccess = true;
|
||||||
break;
|
break; // Exit the retry loop on success
|
||||||
}
|
} catch (rpcError) {
|
||||||
} catch (error) {
|
lastError = `Backend RPC error: ${rpcError instanceof Error ? rpcError.message : 'Unknown error'}`;
|
||||||
lastError = error instanceof Error ? error : String(error);
|
devError(`Backend microphone start failed with RPC error: ${lastError} (attempt ${attempt})`);
|
||||||
devError(`Backend microphone start threw error (attempt ${attempt}):`, error);
|
|
||||||
|
|
||||||
// For network errors, try again after a short delay
|
// For RPC errors, try again after a short delay
|
||||||
if (attempt < 3) {
|
if (attempt < 3) {
|
||||||
devLog(`Retrying backend start in 500ms (attempt ${attempt + 1}/3)...`);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -388,34 +337,11 @@ export function useMicrophone() {
|
||||||
// Save microphone enabled state for auto-restore on page reload
|
// Save microphone enabled state for auto-restore on page reload
|
||||||
setMicrophoneWasEnabled(true);
|
setMicrophoneWasEnabled(true);
|
||||||
|
|
||||||
devLog("Microphone state set to active. Verifying state:", {
|
|
||||||
streamInRef: !!microphoneStreamRef.current,
|
|
||||||
streamInStore: !!microphoneStream,
|
|
||||||
isActive: true,
|
|
||||||
isMuted: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Don't sync immediately after starting - it causes race conditions
|
|
||||||
// The sync will happen naturally through other triggers
|
|
||||||
devOnly(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
// Just verify state after a delay for debugging
|
|
||||||
devLog("State check after delay:", {
|
|
||||||
streamInRef: !!microphoneStreamRef.current,
|
|
||||||
streamInStore: !!microphoneStream,
|
|
||||||
isActive: isMicrophoneActive,
|
|
||||||
isMuted: isMicrophoneMuted
|
|
||||||
});
|
|
||||||
}, AUDIO_CONFIG.AUDIO_TEST_TIMEOUT);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear the starting flag
|
// Clear the starting flag
|
||||||
isStartingRef.current = false;
|
isStartingRef.current = false;
|
||||||
setIsStarting(false);
|
setIsStarting(false);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Failed to start microphone
|
|
||||||
|
|
||||||
let micError: MicrophoneError;
|
let micError: MicrophoneError;
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
|
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
|
||||||
|
|
@ -446,7 +372,7 @@ export function useMicrophone() {
|
||||||
setIsStarting(false);
|
setIsStarting(false);
|
||||||
return { success: false, error: micError };
|
return { success: false, error: micError };
|
||||||
}
|
}
|
||||||
}, [peerConnection, setMicrophoneStream, setMicrophoneSender, setMicrophoneActive, setMicrophoneMuted, setMicrophoneWasEnabled, stopMicrophoneStream, isMicrophoneActive, isMicrophoneMuted, microphoneStream, isStarting, isStopping, isToggling]);
|
}, [peerConnection, setMicrophoneStream, setMicrophoneSender, setMicrophoneActive, setMicrophoneMuted, setMicrophoneWasEnabled, stopMicrophoneStream, isStarting, isStopping, isToggling, rpcMicrophoneStart, rpcDataChannel?.readyState, send]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -463,10 +389,20 @@ export function useMicrophone() {
|
||||||
// First stop the stream
|
// First stop the stream
|
||||||
await stopMicrophoneStream();
|
await stopMicrophoneStream();
|
||||||
|
|
||||||
// Then notify backend that microphone is stopped
|
// Then notify backend that microphone is stopped using RPC
|
||||||
try {
|
try {
|
||||||
await api.POST("/microphone/stop", {});
|
if (rpcDataChannel?.readyState === "open") {
|
||||||
devLog("Backend notified about microphone stop");
|
await new Promise<void>((resolve) => {
|
||||||
|
send("microphoneStop", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
devWarn("RPC microphone stop failed:", resp.error);
|
||||||
|
}
|
||||||
|
resolve(); // Continue regardless of result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
devWarn("RPC connection not available for microphone stop");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
devWarn("Failed to notify backend about microphone stop:", error);
|
devWarn("Failed to notify backend about microphone stop:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -494,7 +430,7 @@ export function useMicrophone() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [stopMicrophoneStream, syncMicrophoneState, setMicrophoneActive, setMicrophoneMuted, setMicrophoneWasEnabled, isStarting, isStopping, isToggling]);
|
}, [stopMicrophoneStream, syncMicrophoneState, setMicrophoneActive, setMicrophoneMuted, setMicrophoneWasEnabled, isStarting, isStopping, isToggling, rpcDataChannel?.readyState, send]);
|
||||||
|
|
||||||
// Toggle microphone mute
|
// Toggle microphone mute
|
||||||
const toggleMicrophoneMute = useCallback(async (): Promise<{ success: boolean; error?: MicrophoneError }> => {
|
const toggleMicrophoneMute = useCallback(async (): Promise<{ success: boolean; error?: MicrophoneError }> => {
|
||||||
|
|
@ -509,21 +445,10 @@ export function useMicrophone() {
|
||||||
// Use the ref instead of store value to avoid race conditions
|
// Use the ref instead of store value to avoid race conditions
|
||||||
const currentStream = microphoneStreamRef.current || microphoneStream;
|
const currentStream = microphoneStreamRef.current || microphoneStream;
|
||||||
|
|
||||||
devLog("Toggle microphone mute - current state:", {
|
|
||||||
hasRefStream: !!microphoneStreamRef.current,
|
|
||||||
hasStoreStream: !!microphoneStream,
|
|
||||||
isActive: isMicrophoneActive,
|
|
||||||
isMuted: isMicrophoneMuted,
|
|
||||||
streamId: currentStream?.id,
|
|
||||||
audioTracks: currentStream?.getAudioTracks().length || 0
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentStream || !isMicrophoneActive) {
|
if (!currentStream || !isMicrophoneActive) {
|
||||||
const errorDetails = {
|
const errorDetails = {
|
||||||
hasStream: !!currentStream,
|
hasStream: !!currentStream,
|
||||||
isActive: isMicrophoneActive,
|
isActive: isMicrophoneActive,
|
||||||
storeStream: !!microphoneStream,
|
|
||||||
refStream: !!microphoneStreamRef.current,
|
|
||||||
streamId: currentStream?.id,
|
streamId: currentStream?.id,
|
||||||
audioTracks: currentStream?.getAudioTracks().length || 0
|
audioTracks: currentStream?.getAudioTracks().length || 0
|
||||||
};
|
};
|
||||||
|
|
@ -564,14 +489,24 @@ export function useMicrophone() {
|
||||||
// Mute/unmute the audio track
|
// Mute/unmute the audio track
|
||||||
audioTracks.forEach((track: MediaStreamTrack) => {
|
audioTracks.forEach((track: MediaStreamTrack) => {
|
||||||
track.enabled = !newMutedState;
|
track.enabled = !newMutedState;
|
||||||
devLog(`Audio track ${track.id} enabled: ${track.enabled}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setMicrophoneMuted(newMutedState);
|
setMicrophoneMuted(newMutedState);
|
||||||
|
|
||||||
// Notify backend about mute state
|
// Notify backend about mute state using RPC
|
||||||
try {
|
try {
|
||||||
await api.POST("/microphone/mute", { muted: newMutedState });
|
if (rpcDataChannel?.readyState === "open") {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
send("microphoneMute", { muted: newMutedState }, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
devWarn("RPC microphone mute failed:", resp.error);
|
||||||
|
}
|
||||||
|
resolve(); // Continue regardless of result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
devWarn("RPC connection not available for microphone mute");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
devWarn("Failed to notify backend about microphone mute:", error);
|
devWarn("Failed to notify backend about microphone mute:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -589,7 +524,7 @@ export function useMicrophone() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [microphoneStream, isMicrophoneActive, isMicrophoneMuted, setMicrophoneMuted, isStarting, isStopping, isToggling]);
|
}, [microphoneStream, isMicrophoneActive, isMicrophoneMuted, setMicrophoneMuted, isStarting, isStopping, isToggling, rpcDataChannel?.readyState, send]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -612,12 +547,16 @@ export function useMicrophone() {
|
||||||
// Sync state on mount and auto-restore microphone if it was enabled before page reload
|
// Sync state on mount and auto-restore microphone if it was enabled before page reload
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const autoRestoreMicrophone = async () => {
|
const autoRestoreMicrophone = async () => {
|
||||||
|
// Wait for RPC connection to be ready before attempting any operations
|
||||||
|
if (rpcDataChannel?.readyState !== "open") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// First sync the current state
|
// First sync the current state
|
||||||
await syncMicrophoneState();
|
await syncMicrophoneState();
|
||||||
|
|
||||||
// If microphone was enabled before page reload and is not currently active, restore it
|
// If microphone was enabled before page reload and is not currently active, restore it
|
||||||
if (microphoneWasEnabled && !isMicrophoneActive && peerConnection) {
|
if (microphoneWasEnabled && !isMicrophoneActive && peerConnection) {
|
||||||
devLog("Auto-restoring microphone after page reload");
|
|
||||||
try {
|
try {
|
||||||
const result = await startMicrophone();
|
const result = await startMicrophone();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
@ -631,8 +570,10 @@ export function useMicrophone() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
autoRestoreMicrophone();
|
// Add a delay to ensure RTC connection is fully established
|
||||||
}, [syncMicrophoneState, microphoneWasEnabled, isMicrophoneActive, peerConnection, startMicrophone]);
|
const timer = setTimeout(autoRestoreMicrophone, 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [syncMicrophoneState, microphoneWasEnabled, isMicrophoneActive, peerConnection, startMicrophone, rpcDataChannel?.readyState]);
|
||||||
|
|
||||||
// Cleanup on unmount - use ref to avoid dependency on stopMicrophoneStream
|
// Cleanup on unmount - use ref to avoid dependency on stopMicrophoneStream
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -640,10 +581,8 @@ export function useMicrophone() {
|
||||||
// Clean up stream directly without depending on the callback
|
// Clean up stream directly without depending on the callback
|
||||||
const stream = microphoneStreamRef.current;
|
const stream = microphoneStreamRef.current;
|
||||||
if (stream) {
|
if (stream) {
|
||||||
devLog("Cleanup: stopping microphone stream on unmount");
|
|
||||||
stream.getAudioTracks().forEach((track: MediaStreamTrack) => {
|
stream.getAudioTracks().forEach((track: MediaStreamTrack) => {
|
||||||
track.stop();
|
track.stop();
|
||||||
devLog(`Cleanup: stopped audio track ${track.id}`);
|
|
||||||
});
|
});
|
||||||
microphoneStreamRef.current = null;
|
microphoneStreamRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,6 @@ import {
|
||||||
} from "react-router";
|
} from "react-router";
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/16/solid";
|
import { ExclamationTriangleIcon } from "@heroicons/react/16/solid";
|
||||||
|
|
||||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
|
||||||
import api from "@/api";
|
|
||||||
import Root from "@/root";
|
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
import EmptyCard from "@components/EmptyCard";
|
import EmptyCard from "@components/EmptyCard";
|
||||||
import NotFoundPage from "@components/NotFoundPage";
|
import NotFoundPage from "@components/NotFoundPage";
|
||||||
|
|
@ -28,6 +25,9 @@ import DeviceIdRename from "@routes/devices.$id.rename";
|
||||||
import DevicesRoute from "@routes/devices";
|
import DevicesRoute from "@routes/devices";
|
||||||
import SettingsIndexRoute from "@routes/devices.$id.settings._index";
|
import SettingsIndexRoute from "@routes/devices.$id.settings._index";
|
||||||
import SettingsAccessIndexRoute from "@routes/devices.$id.settings.access._index";
|
import SettingsAccessIndexRoute from "@routes/devices.$id.settings.access._index";
|
||||||
|
import Root from "@/root";
|
||||||
|
import api from "@/api";
|
||||||
|
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||||
import Notifications from "@/notifications";
|
import Notifications from "@/notifications";
|
||||||
const SignupRoute = lazy(() => import("@routes/signup"));
|
const SignupRoute = lazy(() => import("@routes/signup"));
|
||||||
const LoginRoute = lazy(() => import("@routes/login"));
|
const LoginRoute = lazy(() => import("@routes/login"));
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import { Button, LinkButton } from "@components/Button";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
import { CardHeader } from "@components/CardHeader";
|
import { CardHeader } from "@components/CardHeader";
|
||||||
import DashboardNavbar from "@components/Header";
|
import DashboardNavbar from "@components/Header";
|
||||||
|
import Fieldset from "@components/Fieldset";
|
||||||
import { User } from "@/hooks/stores";
|
import { User } from "@/hooks/stores";
|
||||||
import { checkAuth } from "@/main";
|
import { checkAuth } from "@/main";
|
||||||
import Fieldset from "@components/Fieldset";
|
|
||||||
import { CLOUD_API } from "@/ui.config";
|
import { CLOUD_API } from "@/ui.config";
|
||||||
|
|
||||||
interface LoaderData {
|
interface LoaderData {
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ import { PlusCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/sol
|
||||||
import { TrashIcon } from "@heroicons/react/16/solid";
|
import { TrashIcon } from "@heroicons/react/16/solid";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
import Card, { GridCard } from "@/components/Card";
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
|
import AutoHeight from "@components/AutoHeight";
|
||||||
|
import Card, { GridCard } from "@/components/Card";
|
||||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
import { formatters } from "@/utils";
|
import { formatters } from "@/utils";
|
||||||
import AutoHeight from "@components/AutoHeight";
|
|
||||||
import { InputFieldWithLabel } from "@/components/InputField";
|
import { InputFieldWithLabel } from "@/components/InputField";
|
||||||
import DebianIcon from "@/assets/debian-icon.png";
|
import DebianIcon from "@/assets/debian-icon.png";
|
||||||
import UbuntuIcon from "@/assets/ubuntu-icon.png";
|
import UbuntuIcon from "@/assets/ubuntu-icon.png";
|
||||||
|
|
@ -25,16 +25,17 @@ import NetBootIcon from "@/assets/netboot-icon.svg";
|
||||||
import Fieldset from "@/components/Fieldset";
|
import Fieldset from "@/components/Fieldset";
|
||||||
import { DEVICE_API } from "@/ui.config";
|
import { DEVICE_API } from "@/ui.config";
|
||||||
|
|
||||||
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
|
|
||||||
import notifications from "../notifications";
|
|
||||||
import { isOnDevice } from "../main";
|
|
||||||
import { cx } from "../cva.config";
|
|
||||||
import {
|
import {
|
||||||
MountMediaState,
|
MountMediaState,
|
||||||
RemoteVirtualMediaState,
|
RemoteVirtualMediaState,
|
||||||
useMountMediaStore,
|
useMountMediaStore,
|
||||||
useRTCStore,
|
useRTCStore,
|
||||||
} from "../hooks/stores";
|
} from "../hooks/stores";
|
||||||
|
import { cx } from "../cva.config";
|
||||||
|
import { isOnDevice } from "../main";
|
||||||
|
import notifications from "../notifications";
|
||||||
|
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
|
||||||
|
|
||||||
|
|
||||||
export default function MountRoute() {
|
export default function MountRoute() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useNavigate, useOutletContext } from "react-router";
|
import { useNavigate, useOutletContext } from "react-router";
|
||||||
|
|
||||||
import { GridCard } from "@/components/Card";
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
|
import { GridCard } from "@/components/Card";
|
||||||
import LogoBlue from "@/assets/logo-blue.svg";
|
import LogoBlue from "@/assets/logo-blue.svg";
|
||||||
import LogoWhite from "@/assets/logo-white.svg";
|
import LogoWhite from "@/assets/logo-white.svg";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,14 @@ import Card from "@components/Card";
|
||||||
import { CardHeader } from "@components/CardHeader";
|
import { CardHeader } from "@components/CardHeader";
|
||||||
import { InputFieldWithLabel } from "@components/InputField";
|
import { InputFieldWithLabel } from "@components/InputField";
|
||||||
import DashboardNavbar from "@components/Header";
|
import DashboardNavbar from "@components/Header";
|
||||||
|
import Fieldset from "@components/Fieldset";
|
||||||
import { User } from "@/hooks/stores";
|
import { User } from "@/hooks/stores";
|
||||||
import { checkAuth } from "@/main";
|
import { checkAuth } from "@/main";
|
||||||
import Fieldset from "@components/Fieldset";
|
|
||||||
import { CLOUD_API } from "@/ui.config";
|
import { CLOUD_API } from "@/ui.config";
|
||||||
|
|
||||||
import api from "../api";
|
import api from "../api";
|
||||||
|
|
||||||
|
|
||||||
interface LoaderData {
|
interface LoaderData {
|
||||||
device: { id: string; name: string; user: { googleId: string } };
|
device: { id: string; name: string; user: { googleId: string } };
|
||||||
user: User;
|
user: User;
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import type { LoaderFunction } from "react-router";
|
||||||
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import api from "@/api";
|
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
|
import { TextAreaWithLabel } from "@components/TextArea";
|
||||||
|
import api from "@/api";
|
||||||
import { GridCard } from "@/components/Card";
|
import { GridCard } from "@/components/Card";
|
||||||
import { Button, LinkButton } from "@/components/Button";
|
import { Button, LinkButton } from "@/components/Button";
|
||||||
import { InputFieldWithLabel } from "@/components/InputField";
|
import { InputFieldWithLabel } from "@/components/InputField";
|
||||||
|
|
@ -15,11 +16,12 @@ import notifications from "@/notifications";
|
||||||
import { DEVICE_API } from "@/ui.config";
|
import { DEVICE_API } from "@/ui.config";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { isOnDevice } from "@/main";
|
import { isOnDevice } from "@/main";
|
||||||
import { TextAreaWithLabel } from "@components/TextArea";
|
|
||||||
|
|
||||||
import { LocalDevice } from "./devices.$id";
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
|
||||||
import { CloudState } from "./adopt";
|
import { CloudState } from "./adopt";
|
||||||
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
import { LocalDevice } from "./devices.$id";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface TLSState {
|
export interface TLSState {
|
||||||
mode: "self-signed" | "custom" | "disabled";
|
mode: "self-signed" | "custom" | "disabled";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
|
||||||
import { useState , useEffect } from "react";
|
import { useState , useEffect } from "react";
|
||||||
|
|
||||||
|
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
|
||||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||||
|
|
@ -12,6 +13,7 @@ import { useDeviceStore } from "../hooks/stores";
|
||||||
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
|
|
||||||
export default function SettingsGeneralRoute() {
|
export default function SettingsGeneralRoute() {
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
|
||||||
export default function SettingsGeneralRebootRoute() {
|
export default function SettingsGeneralRebootRoute() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ import { useLocation, useNavigate } from "react-router";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
|
import { Button } from "@components/Button";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import { UpdateState, useUpdateStore } from "@/hooks/stores";
|
import { UpdateState, useUpdateStore } from "@/hooks/stores";
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,16 @@ import { useEffect } from "react";
|
||||||
|
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { SettingsItem } from "@routes/devices.$id.settings";
|
import { SettingsItem } from "@routes/devices.$id.settings";
|
||||||
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
|
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
||||||
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
|
||||||
|
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import { UsbInfoSetting } from "../components/UsbInfoSetting";
|
import { UsbInfoSetting } from "../components/UsbInfoSetting";
|
||||||
import { FeatureFlag } from "../components/FeatureFlag";
|
import { FeatureFlag } from "../components/FeatureFlag";
|
||||||
|
|
||||||
|
|
||||||
export default function SettingsHardwareRoute() {
|
export default function SettingsHardwareRoute() {
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { useSettingsStore } from "@/hooks/stores";
|
import { useSettingsStore } from "@/hooks/stores";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
|
||||||
import { Checkbox } from "@/components/Checkbox";
|
import { Checkbox } from "@/components/Checkbox";
|
||||||
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
|
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
|
|
||||||
export default function SettingsKeyboardRoute() {
|
export default function SettingsKeyboardRoute() {
|
||||||
const { setKeyboardLayout } = useSettingsStore();
|
const { setKeyboardLayout } = useSettingsStore();
|
||||||
const { showPressedKeys, setShowPressedKeys } = useSettingsStore();
|
const { showPressedKeys, setShowPressedKeys } = useSettingsStore();
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { CheckCircleIcon } from "@heroicons/react/16/solid";
|
import { CheckCircleIcon } from "@heroicons/react/16/solid";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import MouseIcon from "@/assets/mouse-icon.svg";
|
|
||||||
import PointingFinger from "@/assets/pointing-finger.svg";
|
|
||||||
import { GridCard } from "@/components/Card";
|
|
||||||
import { Checkbox } from "@/components/Checkbox";
|
|
||||||
import { useSettingsStore } from "@/hooks/stores";
|
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
import { JigglerSetting } from "@components/JigglerSetting";
|
import { JigglerSetting } from "@components/JigglerSetting";
|
||||||
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { useSettingsStore } from "@/hooks/stores";
|
||||||
|
import { Checkbox } from "@/components/Checkbox";
|
||||||
|
import { GridCard } from "@/components/Card";
|
||||||
|
import PointingFinger from "@/assets/pointing-finger.svg";
|
||||||
|
import MouseIcon from "@/assets/mouse-icon.svg";
|
||||||
|
|
||||||
import { cx } from "../cva.config";
|
|
||||||
import notifications from "../notifications";
|
|
||||||
import SettingsNestedSection from "../components/SettingsNestedSection";
|
import SettingsNestedSection from "../components/SettingsNestedSection";
|
||||||
|
import notifications from "../notifications";
|
||||||
|
import { cx } from "../cva.config";
|
||||||
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,15 @@ import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import { LuEthernetPort } from "react-icons/lu";
|
import { LuEthernetPort } from "react-icons/lu";
|
||||||
|
|
||||||
|
import { Button } from "@components/Button";
|
||||||
|
import { GridCard } from "@components/Card";
|
||||||
|
import InputField, { InputFieldWithLabel } from "@components/InputField";
|
||||||
|
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
|
||||||
|
import { SettingsPageHeader } from "@/components/SettingsPageheader";
|
||||||
|
import Fieldset from "@/components/Fieldset";
|
||||||
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import {
|
import {
|
||||||
IPv4Mode,
|
IPv4Mode,
|
||||||
IPv6Mode,
|
IPv6Mode,
|
||||||
|
|
@ -13,20 +22,11 @@ import {
|
||||||
TimeSyncMode,
|
TimeSyncMode,
|
||||||
useNetworkStateStore,
|
useNetworkStateStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import { GridCard } from "@components/Card";
|
|
||||||
import InputField, { InputFieldWithLabel } from "@components/InputField";
|
|
||||||
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
|
|
||||||
import { SettingsPageHeader } from "@/components/SettingsPageheader";
|
|
||||||
import Fieldset from "@/components/Fieldset";
|
|
||||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|
||||||
import notifications from "@/notifications";
|
|
||||||
|
|
||||||
import Ipv6NetworkCard from "../components/Ipv6NetworkCard";
|
|
||||||
import EmptyCard from "../components/EmptyCard";
|
|
||||||
import AutoHeight from "../components/AutoHeight";
|
|
||||||
import DhcpLeaseCard from "../components/DhcpLeaseCard";
|
import DhcpLeaseCard from "../components/DhcpLeaseCard";
|
||||||
|
import AutoHeight from "../components/AutoHeight";
|
||||||
|
import EmptyCard from "../components/EmptyCard";
|
||||||
|
import Ipv6NetworkCard from "../components/Ipv6NetworkCard";
|
||||||
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { useResizeObserver } from "usehooks-ts";
|
import { useResizeObserver } from "usehooks-ts";
|
||||||
|
|
||||||
|
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
import { LinkButton } from "@/components/Button";
|
import { LinkButton } from "@/components/Button";
|
||||||
import { FeatureFlag } from "@/components/FeatureFlag";
|
import { FeatureFlag } from "@/components/FeatureFlag";
|
||||||
|
|
@ -23,6 +24,7 @@ import { useUiStore } from "@/hooks/stores";
|
||||||
|
|
||||||
import { cx } from "../cva.config";
|
import { cx } from "../cva.config";
|
||||||
|
|
||||||
|
|
||||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||||
export default function SettingsRoute() {
|
export default function SettingsRoute() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
|
import Fieldset from "@components/Fieldset";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { TextAreaWithLabel } from "@/components/TextArea";
|
import { TextAreaWithLabel } from "@/components/TextArea";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
|
||||||
import { useSettingsStore } from "@/hooks/stores";
|
import { useSettingsStore } from "@/hooks/stores";
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
|
||||||
import Fieldset from "@components/Fieldset";
|
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
|
|
||||||
const defaultEdid =
|
const defaultEdid =
|
||||||
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
|
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
|
||||||
const edids = [
|
const edids = [
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { CLOUD_API } from "@/ui.config";
|
||||||
|
|
||||||
import api from "../api";
|
import api from "../api";
|
||||||
|
|
||||||
|
|
||||||
const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => {
|
const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => {
|
||||||
await checkAuth();
|
await checkAuth();
|
||||||
const res = await fetch(`${CLOUD_API}/devices/${params.id}`, {
|
const res = await fetch(`${CLOUD_API}/devices/${params.id}`, {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import { FocusTrap } from "focus-trap-react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import useWebSocket from "react-use-websocket";
|
import useWebSocket from "react-use-websocket";
|
||||||
|
|
||||||
|
import WebRTCVideo from "@components/WebRTCVideo";
|
||||||
|
import DashboardNavbar from "@components/Header";
|
||||||
|
import { DeviceStatus } from "@routes/welcome-local";
|
||||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
||||||
|
|
@ -36,11 +39,6 @@ import {
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { useMicrophone } from "@/hooks/useMicrophone";
|
import { useMicrophone } from "@/hooks/useMicrophone";
|
||||||
import { useAudioEvents } from "@/hooks/useAudioEvents";
|
import { useAudioEvents } from "@/hooks/useAudioEvents";
|
||||||
import WebRTCVideo from "@components/WebRTCVideo";
|
|
||||||
import DashboardNavbar from "@components/Header";
|
|
||||||
const ConnectionStatsSidebar = lazy(() => import('@/components/sidebar/connectionStats'));
|
|
||||||
const Terminal = lazy(() => import('@components/Terminal'));
|
|
||||||
const UpdateInProgressStatusCard = lazy(() => import("@/components/UpdateInProgressStatusCard"));
|
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import {
|
import {
|
||||||
|
|
@ -50,10 +48,12 @@ import {
|
||||||
} from "@/components/VideoOverlay";
|
} from "@/components/VideoOverlay";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
|
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
|
||||||
import { DeviceStatus } from "@routes/welcome-local";
|
|
||||||
import audioQualityService from "@/services/audioQualityService";
|
|
||||||
import { useVersion } from "@/hooks/useVersion";
|
import { useVersion } from "@/hooks/useVersion";
|
||||||
|
|
||||||
|
const ConnectionStatsSidebar = lazy(() => import('@/components/sidebar/connectionStats'));
|
||||||
|
const Terminal = lazy(() => import('@components/Terminal'));
|
||||||
|
const UpdateInProgressStatusCard = lazy(() => import("@/components/UpdateInProgressStatusCard"));
|
||||||
|
|
||||||
interface LocalLoaderResp {
|
interface LocalLoaderResp {
|
||||||
authMode: "password" | "noPassword" | null;
|
authMode: "password" | "noPassword" | null;
|
||||||
}
|
}
|
||||||
|
|
@ -573,11 +573,6 @@ export default function KvmIdRoute() {
|
||||||
};
|
};
|
||||||
}, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]);
|
}, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]);
|
||||||
|
|
||||||
// Register callback with audioQualityService
|
|
||||||
useEffect(() => {
|
|
||||||
audioQualityService.setReconnectionCallback(setupPeerConnection);
|
|
||||||
}, [setupPeerConnection]);
|
|
||||||
|
|
||||||
// TURN server usage detection
|
// TURN server usage detection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (peerConnectionState !== "connected") return;
|
if (peerConnectionState !== "connected") return;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
import GridBackground from "@components/GridBackground";
|
||||||
import { LinkButton } from "@/components/Button";
|
import { LinkButton } from "@/components/Button";
|
||||||
import SimpleNavbar from "@/components/SimpleNavbar";
|
import SimpleNavbar from "@/components/SimpleNavbar";
|
||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
import GridBackground from "@components/GridBackground";
|
|
||||||
|
|
||||||
export default function DevicesAlreadyAdopted() {
|
export default function DevicesAlreadyAdopted() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ import ExtLink from "../components/ExtLink";
|
||||||
|
|
||||||
import { DeviceStatus } from "./welcome-local";
|
import { DeviceStatus } from "./welcome-local";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const loader: LoaderFunction = async () => {
|
const loader: LoaderFunction = async () => {
|
||||||
const res = await api
|
const res = await api
|
||||||
.GET(`${DEVICE_API}/device/status`)
|
.GET(`${DEVICE_API}/device/status`)
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import { useState } from "react";
|
||||||
import GridBackground from "@components/GridBackground";
|
import GridBackground from "@components/GridBackground";
|
||||||
import Container from "@components/Container";
|
import Container from "@components/Container";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
|
||||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
|
||||||
import { DEVICE_API } from "@/ui.config";
|
import { DEVICE_API } from "@/ui.config";
|
||||||
|
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
|
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||||
|
|
||||||
import { GridCard } from "../components/Card";
|
import { GridCard } from "../components/Card";
|
||||||
import { cx } from "../cva.config";
|
import { cx } from "../cva.config";
|
||||||
|
|
@ -15,6 +15,7 @@ import api from "../api";
|
||||||
|
|
||||||
import { DeviceStatus } from "./welcome-local";
|
import { DeviceStatus } from "./welcome-local";
|
||||||
|
|
||||||
|
|
||||||
const loader: LoaderFunction = async () => {
|
const loader: LoaderFunction = async () => {
|
||||||
const res = await api
|
const res = await api
|
||||||
.GET(`${DEVICE_API}/device/status`)
|
.GET(`${DEVICE_API}/device/status`)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import api from "../api";
|
||||||
|
|
||||||
import { DeviceStatus } from "./welcome-local";
|
import { DeviceStatus } from "./welcome-local";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const loader: LoaderFunction = async () => {
|
const loader: LoaderFunction = async () => {
|
||||||
const res = await api
|
const res = await api
|
||||||
.GET(`${DEVICE_API}/device/status`)
|
.GET(`${DEVICE_API}/device/status`)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { DEVICE_API } from "@/ui.config";
|
||||||
|
|
||||||
import api from "../api";
|
import api from "../api";
|
||||||
|
|
||||||
|
|
||||||
export interface DeviceStatus {
|
export interface DeviceStatus {
|
||||||
isSetup: boolean;
|
isSetup: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import api from '@/api';
|
import { JsonRpcResponse } from '@/hooks/useJsonRpc';
|
||||||
|
|
||||||
interface AudioConfig {
|
interface AudioConfig {
|
||||||
Quality: number;
|
Quality: number;
|
||||||
|
|
@ -15,6 +15,8 @@ interface AudioQualityResponse {
|
||||||
presets: QualityPresets;
|
presets: QualityPresets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (resp: JsonRpcResponse) => void) => void;
|
||||||
|
|
||||||
class AudioQualityService {
|
class AudioQualityService {
|
||||||
private audioPresets: QualityPresets | null = null;
|
private audioPresets: QualityPresets | null = null;
|
||||||
private microphonePresets: QualityPresets | null = null;
|
private microphonePresets: QualityPresets | null = null;
|
||||||
|
|
@ -24,25 +26,45 @@ class AudioQualityService {
|
||||||
2: 'High',
|
2: 'High',
|
||||||
3: 'Ultra'
|
3: 'Ultra'
|
||||||
};
|
};
|
||||||
private reconnectionCallback: (() => Promise<void>) | null = null;
|
private rpcSend: RpcSendFunction | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch audio quality presets from the backend
|
* Set RPC send function for cloud compatibility
|
||||||
|
*/
|
||||||
|
setRpcSend(rpcSend: RpcSendFunction): void {
|
||||||
|
this.rpcSend = rpcSend;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch audio quality presets using RPC (cloud-compatible)
|
||||||
*/
|
*/
|
||||||
async fetchAudioQualityPresets(): Promise<AudioQualityResponse | null> {
|
async fetchAudioQualityPresets(): Promise<AudioQualityResponse | null> {
|
||||||
|
if (!this.rpcSend) {
|
||||||
|
console.error('RPC not available for audio quality presets');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.GET('/audio/quality');
|
return await new Promise<AudioQualityResponse | null>((resolve) => {
|
||||||
if (response.ok) {
|
this.rpcSend!("audioQualityPresets", {}, (resp: JsonRpcResponse) => {
|
||||||
const data = await response.json();
|
if ("error" in resp) {
|
||||||
|
console.error('RPC audio quality presets failed:', resp.error);
|
||||||
|
resolve(null);
|
||||||
|
} else if ("result" in resp) {
|
||||||
|
const data = resp.result as AudioQualityResponse;
|
||||||
this.audioPresets = data.presets;
|
this.audioPresets = data.presets;
|
||||||
this.updateQualityLabels(data.presets);
|
this.updateQualityLabels(data.presets);
|
||||||
return data;
|
resolve(data);
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch audio quality presets:', error);
|
console.error('Failed to fetch audio quality presets:', error);
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update quality labels with actual bitrates from presets
|
* Update quality labels with actual bitrates from presets
|
||||||
|
|
@ -80,34 +102,25 @@ class AudioQualityService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set reconnection callback for WebRTC reset
|
* Set audio quality using RPC (cloud-compatible)
|
||||||
*/
|
|
||||||
setReconnectionCallback(callback: () => Promise<void>): void {
|
|
||||||
this.reconnectionCallback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger audio track replacement using backend's track replacement mechanism
|
|
||||||
*/
|
|
||||||
private async replaceAudioTrack(): Promise<void> {
|
|
||||||
if (this.reconnectionCallback) {
|
|
||||||
await this.reconnectionCallback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set audio quality with track replacement
|
|
||||||
*/
|
*/
|
||||||
async setAudioQuality(quality: number): Promise<boolean> {
|
async setAudioQuality(quality: number): Promise<boolean> {
|
||||||
try {
|
if (!this.rpcSend) {
|
||||||
const response = await api.POST('/audio/quality', { quality });
|
console.error('RPC not available for audio quality change');
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.replaceAudioTrack();
|
try {
|
||||||
return true;
|
return await new Promise<boolean>((resolve) => {
|
||||||
|
this.rpcSend!("audioQuality", { quality }, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
console.error('RPC audio quality change failed:', resp.error);
|
||||||
|
resolve(false);
|
||||||
|
} else {
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to set audio quality:', error);
|
console.error('Failed to set audio quality:', error);
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
21
web.go
21
web.go
|
|
@ -20,6 +20,7 @@ import (
|
||||||
gin_logger "github.com/gin-contrib/logger"
|
gin_logger "github.com/gin-contrib/logger"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jetkvm/kvm/internal/audio"
|
||||||
"github.com/jetkvm/kvm/internal/logging"
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
"github.com/pion/webrtc/v4"
|
"github.com/pion/webrtc/v4"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
@ -184,16 +185,6 @@ func setupRouter() *gin.Engine {
|
||||||
protected.PUT("/auth/password-local", handleUpdatePassword)
|
protected.PUT("/auth/password-local", handleUpdatePassword)
|
||||||
protected.DELETE("/auth/local-password", handleDeletePassword)
|
protected.DELETE("/auth/local-password", handleDeletePassword)
|
||||||
protected.POST("/storage/upload", handleUploadHttp)
|
protected.POST("/storage/upload", handleUploadHttp)
|
||||||
|
|
||||||
// Audio handlers
|
|
||||||
protected.GET("/audio/status", handleAudioStatus)
|
|
||||||
protected.POST("/audio/mute", handleAudioMute)
|
|
||||||
protected.GET("/audio/quality", handleAudioQuality)
|
|
||||||
protected.POST("/audio/quality", handleSetAudioQuality)
|
|
||||||
protected.POST("/microphone/start", handleMicrophoneStart)
|
|
||||||
protected.POST("/microphone/stop", handleMicrophoneStop)
|
|
||||||
protected.POST("/microphone/mute", handleMicrophoneMute)
|
|
||||||
protected.POST("/microphone/reset", handleMicrophoneReset)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catch-all route for SPA
|
// Catch-all route for SPA
|
||||||
|
|
@ -243,6 +234,16 @@ func handleWebRTCSession(c *gin.Context) {
|
||||||
cancelKeyboardMacro()
|
cancelKeyboardMacro()
|
||||||
|
|
||||||
currentSession = session
|
currentSession = session
|
||||||
|
|
||||||
|
// Set up audio relay callback to get current session's audio track
|
||||||
|
// This is needed for audio output to work after enable/disable cycles
|
||||||
|
audio.SetCurrentSessionCallback(func() audio.AudioTrackWriter {
|
||||||
|
if currentSession != nil {
|
||||||
|
return currentSession.AudioTrack
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"sd": sd})
|
c.JSON(http.StatusOK, gin.H{"sd": sd})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue