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:
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user