import { useEffect, useRef } from 'react'; import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon, PinIcon, WifiLowIcon, WifiOffIcon, } from './icons'; /** Discord-style connection-quality badge state. Matches LiveKit's * ConnectionQuality enum values so consumers can pass the value through * without conversion. `unknown` and `excellent`/`good` render nothing — the * badge only surfaces when the user actually has degraded media. */ export type TileConnectionQuality = 'excellent' | 'good' | 'poor' | 'lost' | 'unknown'; 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; /** True when the participant has deafened. Propagated from peers via the * LiveKit data channel, so remote tiles surface the headphones-off badge * the same way the local tile does. */ deafened: boolean; /** Discord-style: true if this participant is the call host (initiator). * Drives the crown badge. Hidden in 1:1 calls — set false there. */ isHost?: boolean; /** True iff the user has pinned this tile via right-click → "Anpinnen". * Renders a small pin badge top-left and locks the focus mode to this * tile regardless of who is currently speaking. */ pinned?: boolean; /** Discord-style "LIVE" pill — true iff this participant is currently * publishing a screen share. Shown on the user tile so observers know * the share-tile next to it belongs to this person. */ streaming?: 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; /** SFU-measured connection quality. Only `poor` and `lost` show a badge. */ connectionQuality?: TileConnectionQuality; size?: 'default' | 'small'; focused?: boolean; onClick?: () => void; onDoubleClick?: () => void; onContextMenu?: (e: React.MouseEvent) => void; } export function CallParticipantTile(props: ParticipantTileProps) { const { displayName, me, muted, deafened, speaking, video, e2ee, connectionQuality, isHost = false, pinned = false, streaming = false, size = 'default', focused = false, onClick, onDoubleClick, onContextMenu, } = props; const small = size === 'small'; // Discord-style: full tile border switches to emerald the whole time the // user is speaking. Same treatment for audio + video tiles so the visual // is consistent regardless of camera state. The inner-glow span on top of // it adds a subtle inset highlight that reads even when the underlying // tile is bright (e.g. a video stream). const borderClass = speaking ? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.35)] dark:border-emerald-400' : focused ? 'border-accent' : 'border-line hover:border-accent'; return (
{video ? ( ) : ( )} {speaking && (
); } /** Discord-style: hidden when excellent/good/unknown, amber when poor, * red with a struck-through Wifi when lost. Anchored top-right above the * speaking-glow so it stays readable on a bright video stream. */ function ConnectionQualityBadge({ quality, small, }: { quality: TileConnectionQuality | undefined; small: boolean; }) { if (!quality || quality === 'excellent' || quality === 'good' || quality === 'unknown') { return null; } const isLost = quality === 'lost'; const Icon = isLost ? WifiOffIcon : WifiLowIcon; const label = isLost ? 'Verbindung verloren' : 'Schlechte Verbindung'; const tone = isLost ? 'bg-rose-500/85 text-white' : 'bg-amber-400/90 text-amber-950 dark:text-amber-950'; const sz = small ? 'h-5 w-5' : 'h-6 w-6'; const iconSz = small ? 'h-3 w-3' : 'h-3.5 w-3.5'; return ( ); } function AudioContent({ userId, displayName, avatarUrl, small, }: ParticipantTileProps & { small: boolean }) { const key = colorKeyFor(userId); const colors = AVATAR_COLORS[key]; const letter = displayName.trim().charAt(0).toUpperCase() || '?'; return (
{avatarUrl ? ( ) : ( {letter} )}
); } function VideoStub({ userId, displayName, avatarUrl, videoTrack, me, small, fit, }: ParticipantTileProps & { small: boolean; fit: 'cover' | 'contain' }) { 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 )}
); }