feat(phase6): video grid, noise suppression, whisper system

- New VideoGrid component: live camera feeds (local + remote) in VoicePanel
- Camera toggle in VoiceControls: enable/disable video per participant
- Noise suppression toggle in VoiceControls + voice store
- VOICE_WHISPER WS event: backend routes whisper to target user only
- Whisper UI: per-participant whisper button, notification toasts with dismiss
- Fix circular import between voice.ts and ws.ts (use lazy accessor)
This commit is contained in:
2026-06-30 12:21:53 -04:00
parent 3c063d4f56
commit 775bd953b0
8 changed files with 307 additions and 28 deletions
+17
View File
@@ -142,6 +142,23 @@ func (c *Client) readPump() {
switch event.Type {
case EventTypingStart, EventPresenceUpdate:
c.Hub.BroadcastEvent(event)
case EventVoiceWhisper:
// Forward voice whispers only to the target user, not broadcast
var whisperData struct {
TargetUserID string `json:"target_user_id"`
FromUserID string `json:"from_user_id"`
FromUsername string `json:"from_username"`
Message string `json:"message,omitempty"`
}
if raw, ok := event.Data.(json.RawMessage); ok {
if err := json.Unmarshal(raw, &whisperData); err == nil {
whisperData.FromUserID = c.UserID
c.Hub.SendToUser(whisperData.TargetUserID, Event{
Type: EventVoiceWhisper,
Data: whisperData,
})
}
}
default:
c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID)
}
+1
View File
@@ -22,6 +22,7 @@ const (
EventVoiceLeave = "VOICE_LEAVE"
EventVoiceMute = "VOICE_MUTE"
EventVoiceDeafen = "VOICE_DEAFEN"
EventVoiceWhisper = "VOICE_WHISPER"
)
// Event represents a WebSocket event sent to clients.
+22
View File
@@ -227,6 +227,28 @@ func (h *Hub) ClientCount() int {
return len(h.clients)
}
// SendToUser sends an event only to the specified user's connected client(s).
func (h *Hub) SendToUser(userID string, event Event) {
data, err := json.Marshal(event)
if err != nil {
h.logger.Error("failed to marshal event", "type", event.Type, "error", err)
return
}
h.mu.RLock()
defer h.mu.RUnlock()
for client := range h.clients {
if client.UserID == userID {
select {
case client.send <- data:
default:
go func(c *Client) { h.unregister <- c }(client)
}
}
}
}
// RefreshUserServers loads the server membership for a user from the database
// and caches it. Call when a client connects or when server membership changes.
func (h *Hub) RefreshUserServers(userID string) {