import { useEffect, useRef } from 'react'; import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon } from './icons'; export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal'; const AVATAR_COLORS: Record = { // Tailwind classes tuned per spec: violet/amber/rose/teal swatches in both // light and dark modes. Dark pairs invert to keep contrast readable. violet: { bg: 'bg-violet-200 dark:bg-violet-900/60', fg: 'text-violet-800 dark:text-violet-200', }, amber: { bg: 'bg-amber-200 dark:bg-amber-900/60', fg: 'text-amber-900 dark:text-amber-200', }, rose: { bg: 'bg-rose-200 dark:bg-rose-900/60', fg: 'text-rose-900 dark:text-rose-200', }, teal: { bg: 'bg-teal-200 dark:bg-teal-900/60', fg: 'text-teal-900 dark:text-teal-200', }, }; const COLOR_KEYS: AvatarColorKey[] = ['violet', 'amber', 'rose', 'teal']; // Stable per-user color from identity string — keeps avatars visually // consistent across re-mounts without needing a color field on the profile. export function colorKeyFor(id: string): AvatarColorKey { let hash = 0; for (let i = 0; i < id.length; i++) { hash = (hash * 31 + id.charCodeAt(i)) >>> 0; } return COLOR_KEYS[hash % COLOR_KEYS.length]!; } export interface ParticipantTileProps { userId: string; displayName: string; avatarUrl: string | null; me: boolean; muted: boolean; /** Local-only: true when THIS user has muted everyone else via the deafen * toggle. Remote deafen state is not propagated, so only the own tile * ever carries a truthy value. */ deafened: boolean; speaking: boolean; video: boolean; e2ee: boolean; /** MediaStreamTrack for the participant's active camera, when `video` is * true. When null the tile falls back to the avatar placeholder. */ videoTrack?: MediaStreamTrack | null; size?: 'default' | 'small'; focused?: boolean; onClick?: () => void; onContextMenu?: (e: React.MouseEvent) => void; } export function CallParticipantTile(props: ParticipantTileProps) { const { displayName, me, muted, deafened, speaking, video, e2ee, size = 'default', focused = false, onClick, onContextMenu, } = props; const small = size === 'small'; // Split the speaking indicator per-mode so we don't stack a tile border // + inset glow on top of the avatar pulse (visual double-chrome). Video // tiles get the border (the avatar is hidden behind the stream so the // pulse wouldn't be visible anyway); audio tiles rely on the avatar // pulse rendered inside AudioContent. const videoSpeaking = speaking && video; const borderClass = videoSpeaking ? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400' : focused ? 'border-accent' : 'border-line hover:border-accent'; return (
{video ? ( ) : ( )} {/* Video-only speaking indicator. Audio tiles use the avatar pulse from AudioContent so we don't double-render chrome. z-10 keeps it above the video element. */} {videoSpeaking && (
); } function AudioContent({ userId, displayName, avatarUrl, speaking, small, }: ParticipantTileProps & { small: boolean }) { const key = colorKeyFor(userId); const colors = AVATAR_COLORS[key]; const letter = displayName.trim().charAt(0).toUpperCase() || '?'; return (
{speaking && (
); } function VideoStub({ userId, displayName, avatarUrl, videoTrack, me, small, }: ParticipantTileProps & { small: boolean }) { const videoRef = useRef(null); useEffect(() => { const el = videoRef.current; if (!el || !videoTrack) return; el.srcObject = new MediaStream([videoTrack]); return () => { if (el.srcObject) { (el.srcObject as MediaStream).getTracks().forEach((t) => { // Don't stop the live track — other consumers may still need it. // Just detach from this element. void t; }); el.srcObject = null; } }; }, [videoTrack]); if (videoTrack) { return (
); } // Camera flag true but no track yet (publishing / subscribing race). const key = colorKeyFor(userId); const colors = AVATAR_COLORS[key]; const letter = displayName.trim().charAt(0).toUpperCase() || '?'; return (
{avatarUrl ? ( ) : ( letter )}
); }