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:
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Track } from 'livekit-client';
|
||||
import type { Participant, TrackPublication, Room } from 'livekit-client';
|
||||
import { useVoiceStore } from '../stores/voice.ts';
|
||||
|
||||
function VideoTile({ participant, isLocal, room }: { participant: Participant; isLocal: boolean; room: Room }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
|
||||
let pub: TrackPublication | undefined;
|
||||
|
||||
const attachTrack = () => {
|
||||
pub = participant.getTrackPublication(Track.Source.Camera);
|
||||
|
||||
if (pub?.track && el) {
|
||||
pub.track.attach(el);
|
||||
el.muted = isLocal;
|
||||
el.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const detachTrack = () => {
|
||||
if (pub?.track && el) {
|
||||
pub.track.detach(el);
|
||||
}
|
||||
};
|
||||
|
||||
attachTrack();
|
||||
|
||||
// Listen for track publications changing on this participant via room events
|
||||
const handlePublished = (pub: TrackPublication) => {
|
||||
pub.track?.attach(el);
|
||||
el.play().catch(() => {});
|
||||
};
|
||||
room.on('trackPublished' as any, handlePublished);
|
||||
|
||||
return () => {
|
||||
detachTrack();
|
||||
room.off('trackPublished' as any, handlePublished);
|
||||
};
|
||||
}, [participant, isLocal, room]);
|
||||
|
||||
const username = participant.name || participant.identity;
|
||||
|
||||
return (
|
||||
<div className="relative bg-gb-bg rounded-sm overflow-hidden border border-gb-bg-t aspect-video flex items-center justify-center">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
<div className="absolute bottom-1 left-1 text-xxs text-gb-fg-s bg-gb-bg/70 px-1 py-0.5 rounded-sm">
|
||||
{username}{isLocal ? ' (you)' : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoGrid() {
|
||||
const room = useVoiceStore((s) => s._room);
|
||||
const isVideoOn = useVoiceStore((s) => s.isVideoOn);
|
||||
|
||||
if (!room || !isVideoOn) return null;
|
||||
|
||||
const participants: Participant[] = [
|
||||
room.localParticipant,
|
||||
...Array.from(room.remoteParticipants.values()),
|
||||
];
|
||||
|
||||
const hasCam = participants.filter((p) => {
|
||||
const pub = p.getTrackPublication(Track.Source.Camera);
|
||||
return pub && !pub.isMuted;
|
||||
});
|
||||
|
||||
if (hasCam.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="px-3 py-1.5 border-b border-gb-bg-t">
|
||||
<div className="text-xxs text-gb-fg-f mb-1">
|
||||
── video [{hasCam.length}] ──
|
||||
</div>
|
||||
<div
|
||||
className={`grid gap-1 ${
|
||||
hasCam.length <= 2
|
||||
? 'grid-cols-2'
|
||||
: hasCam.length <= 4
|
||||
? 'grid-cols-2'
|
||||
: 'grid-cols-3'
|
||||
}`}
|
||||
>
|
||||
{participants.map((p) => (
|
||||
<VideoTile
|
||||
key={p.identity}
|
||||
participant={p}
|
||||
isLocal={p.identity === room.localParticipant.identity}
|
||||
room={room}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,12 @@ export function VoiceControls() {
|
||||
const isDeafened = useVoiceStore((state) => state.isDeafened);
|
||||
const isVideoOn = useVoiceStore((state) => state.isVideoOn);
|
||||
const isScreenSharing = useVoiceStore((state) => state.isScreenSharing);
|
||||
const noiseSuppression = useVoiceStore((state) => state.noiseSuppression);
|
||||
const toggleMute = useVoiceStore((state) => state.toggleMute);
|
||||
const toggleDeafen = useVoiceStore((state) => state.toggleDeafen);
|
||||
const toggleVideo = useVoiceStore((state) => state.toggleVideo);
|
||||
const toggleScreenShare = useVoiceStore((state) => state.toggleScreenShare);
|
||||
const toggleNoiseSuppression = useVoiceStore((state) => state.toggleNoiseSuppression);
|
||||
const leaveVoice = useVoiceStore((state) => state.leaveVoice);
|
||||
|
||||
return (
|
||||
@@ -57,6 +59,17 @@ export function VoiceControls() {
|
||||
>
|
||||
[share]
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleNoiseSuppression}
|
||||
className={`px-1.5 py-0.5 rounded-sm transition-colors ${
|
||||
noiseSuppression
|
||||
? 'text-gb-green bg-gb-bg-s border border-gb-green'
|
||||
: 'text-gb-fg-s hover:text-gb-green hover:bg-gb-bg-t border border-transparent'
|
||||
}`}
|
||||
title={noiseSuppression ? 'Noise suppression on' : 'Noise suppression off'}
|
||||
>
|
||||
[ns{noiseSuppression ? '●' : '○'}]
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={leaveVoice}
|
||||
|
||||
@@ -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