775bd953b0
- 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)
107 lines
2.9 KiB
TypeScript
107 lines
2.9 KiB
TypeScript
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>
|
|
);
|
|
}
|