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
+74 -28
View File
@@ -1,5 +1,7 @@
import { useVoiceStore } from '../stores/voice.ts';
import { useAuthStore } from '../stores/auth.ts';
import { VoiceControls } from './VoiceControls.tsx';
import { VideoGrid } from './VideoGrid.tsx';
export function VoicePanel() {
const isConnected = useVoiceStore((state) => state.isConnected);
@@ -7,6 +9,10 @@ export function VoicePanel() {
const currentRoomName = useVoiceStore((state) => state.currentRoomName);
const participants = useVoiceStore((state) => state.participants);
const error = useVoiceStore((state) => state.error);
const whispers = useVoiceStore((state) => state.whispers);
const whisperTo = useVoiceStore((state) => state.whisperTo);
const dismissWhisper = useVoiceStore((state) => state.dismissWhisper);
const currentUserId = useAuthStore((state) => state.user?.id);
if (!currentRoom) return null;
@@ -38,6 +44,31 @@ export function VoicePanel() {
</div>
)}
{/* Incoming whispers */}
{whispers.length > 0 && (
<div className="px-3 py-1.5 border-b border-gb-bg-t">
<div className="text-xxs text-gb-fg-f mb-1">
whispers
</div>
{whispers.map((w) => (
<div
key={w.timestamp}
className="flex items-center gap-2 py-0.5 text-xs text-gb-orange"
>
<span>🔊</span>
<span className="font-bold">{w.fromUsername}</span>
<span className="text-gb-fg-s truncate">{w.message || 'whispered'}</span>
<button
className="ml-auto text-xxs text-gb-fg-f hover:text-gb-fg-s"
onClick={() => dismissWhisper(w.timestamp)}
>
[x]
</button>
</div>
))}
</div>
)}
{/* Participants */}
<div className="px-3 py-1.5 border-b border-gb-bg-t">
<div className="text-xxs text-gb-fg-f mb-1">
@@ -46,37 +77,52 @@ export function VoicePanel() {
{participants.length === 0 && (
<div className="text-xxs text-gb-fg-f">[empty channel]</div>
)}
{participants.map((p) => (
<div
key={p.identity}
className="flex items-center gap-2 py-0.5 text-xs"
>
<span
className={
p.isSpeaking
? 'text-gb-green'
: p.isMuted
? 'text-gb-red'
: 'text-gb-fg-s'
}
{participants.map((p) => {
const isMe = p.identity === currentUserId;
return (
<div
key={p.identity}
className="flex items-center gap-2 py-0.5 text-xs"
>
{p.isMuted ? '○' : '●'}
</span>
<span
className={p.isSpeaking ? 'text-gb-green' : 'text-gb-fg-s'}
>
{p.username}
</span>
{p.isMuted && (
<span className="text-xxs text-gb-red">[muted]</span>
)}
{p.isSpeaking && (
<span className="text-xxs text-gb-green">[speaking]</span>
)}
</div>
))}
<span
className={
p.isSpeaking
? 'text-gb-green'
: p.isMuted
? 'text-gb-red'
: 'text-gb-fg-s'
}
>
{p.isMuted ? '○' : '●'}
</span>
<span
className={p.isSpeaking ? 'text-gb-green' : 'text-gb-fg-s'}
>
{p.username}
</span>
{p.isMuted && (
<span className="text-xxs text-gb-red">[muted]</span>
)}
{p.isSpeaking && (
<span className="text-xxs text-gb-green">[speaking]</span>
)}
{!isMe && (
<button
className="ml-auto text-xxs text-gb-fg-f hover:text-gb-orange"
onClick={() => whisperTo(p.identity, p.username)}
title={`Whisper to ${p.username}`}
>
[whisper]
</button>
)}
</div>
);
})}
</div>
{/* Video grid */}
<VideoGrid />
{/* Controls */}
<div className="px-3 py-1.5">
<VoiceControls />