import React, { useRef, useEffect, useState, useCallback } from 'react'; import { ProfileAvatar } from '../ui/ProfileAvatar'; import { useVoiceStore } from '../../stores/voiceStore'; import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore'; import { buildVoiceModMenuItems, VolumeSliderItem } from './voiceMenuItems'; import { useSpaceStore } from '../../stores/spaceStore'; import { useUIStore } from '../../stores/uiStore'; import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta'; import { getActiveRoom, setCameraSubscription } from '../../hooks/useLiveKit'; 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 openContextMenu = useContextMenuStore((s) => s.open); const [, forceUpdate] = useState(0); const isLocal = participant.isLocal; const avatarUserId = participant.homeUserId ?? participant.userId; const { displayName, avatar, user } = useVoiceParticipantMeta(participant); const openUserProfile = useUIStore((s) => s.openUserProfile); // --- 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 handleContextMenu = useCallback( (e: React.MouseEvent) => { if (isLocal || !currentVoiceChannelId) return; e.preventDefault(); e.stopPropagation(); const targetUserId = participant.userId; const channelId = currentVoiceChannelId; // Build moderation items const modItems = buildVoiceModMenuItems(targetUserId, channelId); const items: ContextMenuItem[] = [...modItems]; // Separator after mod items if (modItems.length > 0) { items.push({ key: 'mod-end-sep', type: 'separator' }); } // Camera watch/unwatch const targetParticipant = useVoiceStore.getState().participants.find((p) => p.userId === targetUserId); const targetHasCamera = targetParticipant?.isCameraOn ?? false; const isCameraUnwatched = useVoiceStore.getState().unwatchedCameras.has(targetUserId); if (targetHasCamera) { items.push({ key: 'camera-toggle', type: 'action', label: isCameraUnwatched ? 'Watch Camera' : 'Stop Watching Camera', icon: React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'currentColor', className: 'flex-shrink-0' }, ...(isCameraUnwatched ? [React.createElement('path', { key: 'cam', d: 'M17 10.5V7c0-.55-.45-1-1-1H2c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h14c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z' })] : [ React.createElement('path', { key: 'cam', d: 'M17 10.5V7c0-.55-.45-1-1-1H2c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h14c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z' }), React.createElement('line', { key: 'slash', x1: 1, y1: 1, x2: 23, y2: 23, stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' }), ]), ), onClick: () => { const room = getActiveRoom(); const identity = targetParticipant?.identity; if (isCameraUnwatched) { useVoiceStore.getState().rewatchCamera(targetUserId); if (identity) setCameraSubscription(room, identity, true); } else { useVoiceStore.getState().unwatchCamera(targetUserId); if (identity) setCameraSubscription(room, identity, false); } }, }); items.push({ key: 'camera-sep', type: 'separator' }); } // Mute User checkbox items.push({ key: 'mute-user', type: 'checkbox', label: 'Mute User', subscribe: useVoiceStore.subscribe, getChecked: () => useVoiceStore.getState().participantMutes.get(targetUserId) ?? false, onChange: (checked) => useVoiceStore.getState().setParticipantMute(targetUserId, checked), }); items.push({ key: 'vol-sep', type: 'separator' }); // Volume slider (custom, needs store subscription via wrapper component) items.push({ key: 'volume', type: 'custom', render: () => React.createElement(VolumeSliderItem, { userId: targetUserId }), }); openContextMenu({ x: e.clientX, y: e.clientY }, items); }, [isLocal, currentVoiceChannelId, participant.userId, openContextMenu], ); return (
{hasVideo ? (