import React, { useRef, useEffect, useState, useCallback } from 'react'; import { Avatar } from '../ui/Avatar'; import { useVoiceStore } from '../../stores/voiceStore'; import { VoiceUserContextMenu } from './VoiceUserContextMenu'; import { useSpaceStore } from '../../stores/spaceStore'; import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta'; import type { UserTile } from '../../hooks/useLiveKit'; interface VoiceUserProps { tile: UserTile; large?: boolean; } export function VoiceUser({ tile, large }: VoiceUserProps) { const videoRef = useRef(null); const { participant } = tile; const isDeafened = useVoiceStore((s) => s.isDeafened); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity)); const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds); const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras); const participantMutes = useVoiceStore((s) => s.participantMutes); const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null); const [, forceUpdate] = useState(0); const isLocal = participant.isLocal; const avatarUserId = participant.homeUserId ?? participant.userId; const { displayName, avatar, user } = useVoiceParticipantMeta(participant); // --- VIDEO & UI --- const activeVideoTrack = tile.videoTrack; const hasVideo = activeVideoTrack !== null; // Force re-render when tracks end/mute useEffect(() => { if (!tile.videoTrack) return; const onEnded = () => forceUpdate((n) => n + 1); tile.videoTrack.addEventListener('ended', onEnded); return () => tile.videoTrack?.removeEventListener('ended', onEnded); }, [tile.videoTrack]); // Attach Video — use LiveKit's track.attach() to register the element // with the adaptive stream observer (enables SFU layer switching by viewport size) useEffect(() => { const videoEl = videoRef.current; if (!videoEl) return; const lkTrack = tile.lkVideoTrack; if (lkTrack) { lkTrack.attach(videoEl); return () => { lkTrack.detach(videoEl); }; } else { videoEl.srcObject = null; } }, [tile.lkVideoTrack]); // Context Menu const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number; } | null>(null); const handleContextMenu = useCallback( (e: React.MouseEvent) => { if (isLocal) return; e.preventDefault(); setVolumeMenu({ x: e.clientX, y: e.clientY }); }, [isLocal], ); return (
{hasVideo ? (