import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { getParticipantVolume, setParticipantVolume, subscribeParticipantVolumes, } from '../lib/participantVolumes'; import { AtIcon, MicIcon, MicOffIcon, PinIcon, PinOffIcon, } from './icons'; interface Props { userId: string; displayName: string; x: number; y: number; /** Discord-style pin toggle row at the top of the menu. When undefined, * the row is hidden — used for tiles that don't make sense to pin. */ pinned?: boolean; onTogglePin?: () => void; /** Hide the volume slider — for self-tiles where the slider would adjust * the local user's own playback gain (which we don't expose). Defaults to * true so existing call sites keep their behaviour. */ renderVolume?: boolean; /** Discord-parity: open this user's profile card from the menu. When * undefined the row is hidden (e.g. for self-tiles where it'd just open * your own profile). */ onShowProfile?: () => void; onClose: () => void; } const MENU_W = 240; const MENU_H = 138; export function ParticipantVolumeMenu({ userId, displayName, x, y, pinned, onTogglePin, renderVolume = true, onShowProfile, onClose, }: Props) { const [volume, setVolume] = useState(() => getParticipantVolume(userId)); // Remembers the volume before "Für mich stummschalten" so unmute restores // it instead of snapping back to 100%. const preMuteRef = useRef(volume > 0 ? volume : null); // Re-sync from store in case another menu instance changed the same user. useEffect(() => subscribeParticipantVolumes(() => { setVolume(getParticipantVolume(userId)); }), [userId]); // Outside click + Esc to close. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; const onDown = (e: MouseEvent) => { const target = e.target as HTMLElement | null; if (target?.closest('[data-volume-menu]')) return; onClose(); }; window.addEventListener('keydown', onKey); window.addEventListener('mousedown', onDown); return () => { window.removeEventListener('keydown', onKey); window.removeEventListener('mousedown', onDown); }; }, [onClose]); const left = Math.min(Math.max(8, x), window.innerWidth - MENU_W - 8); const top = Math.min(Math.max(8, y), window.innerHeight - MENU_H - 8); return createPortal(
{displayName} {renderVolume && ( 1 ? 'text-amber-500' : 'text-fg-muted') } > {Math.round(volume * 100)}% )}
{onShowProfile && ( )} {renderVolume && ( )} {onTogglePin && ( )} {renderVolume && ( <> {/* Up to 200% via WebAudio gain. Values above 100% can clip on loud mics — the amber count-up hints at that without a verbose warning. */} { const v = Number(e.target.value); setVolume(v); setParticipantVolume(userId, v); }} aria-label={'Lautstärke ' + displayName} className="w-full accent-accent" />
0% 100% 200%
)}
, document.body, ); }