import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; import { Copy, Trash2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useOrbitStore } from '../store/orbitStore'; import { usePlayerStore } from '../store/playerStore'; import { showToast } from '../utils/ui/toast'; import { computeOrbitDriftMs } from '../utils/orbit'; import { clearOrbitEvents, formatOrbitEvents, getOrbitEvents, subscribeOrbitEvents, } from '../utils/orbitDiag'; interface Props { anchorRef: React.RefObject; onClose: () => void; } /** * Live diagnostic popover for the Orbit bar. Renders a mini-summary of the * host vs guest state plus a scrolling event log captured by `orbitDiag`. * * The point is "Discord user can paste a buffer" — so the Copy button is * the primary action and the textarea is read-only. Clear lets a user wipe * the buffer before reproducing a specific symptom. */ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) { const { t } = useTranslation(); const popRef = useRef(null); const taRef = useRef(null); // Live event buffer subscription via useSyncExternalStore — no extra // re-render cascade, no flicker. const events = useSyncExternalStore(subscribeOrbitEvents, getOrbitEvents, getOrbitEvents); const formatted = formatOrbitEvents(events); // Tick the mini-display once a second so drift / position read fresh. const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { const id = window.setInterval(() => setNowMs(Date.now()), 1000); return () => window.clearInterval(id); }, []); // Auto-scroll the textarea to the bottom whenever a new event lands so // the most recent line is always visible without manual scrolling. useEffect(() => { if (taRef.current) taRef.current.scrollTop = taRef.current.scrollHeight; }, [formatted]); useEffect(() => { const onDown = (e: MouseEvent) => { const target = e.target as Node | null; if (popRef.current?.contains(target)) return; if (anchorRef.current?.contains(target)) return; onClose(); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; }, [anchorRef, onClose]); const anchor = anchorRef.current?.getBoundingClientRect(); const style: React.CSSProperties = anchor ? { position: 'fixed', top: anchor.bottom + 12, right: Math.max(8, window.innerWidth - anchor.right), zIndex: 9999, } : { display: 'none' }; // ── Live mini-display data ──────────────────────────────────────────── const role = useOrbitStore(s => s.role); const state = useOrbitStore(s => s.state); const player = usePlayerStore.getState(); const localPosMs = Math.round((player.currentTime ?? 0) * 1000); const sameTrack = role === 'guest' && state?.currentTrack && player.currentTrack?.id === state.currentTrack.trackId; const driftMs = sameTrack && state ? computeOrbitDriftMs(state, localPosMs, nowMs) : null; const hostStateAgeMs = state ? Math.max(0, nowMs - state.positionAt) : null; const hostPosSec = state ? Math.round(((state.positionMs ?? 0) + (state.isPlaying ? (nowMs - state.positionAt) : 0)) / 1000) : null; const guestPosSec = Math.round((player.currentTime ?? 0)); const handleCopy = async () => { const text = formatted || '(empty)'; try { await navigator.clipboard.writeText(text); showToast(t('orbit.diag.copied', { count: events.length }), 2500, 'info'); } catch { showToast(t('orbit.diag.copyFailed'), 4000, 'error'); } }; const handleClear = () => { clearOrbitEvents(); showToast(t('orbit.diag.cleared'), 2000, 'info'); }; return createPortal(
{t('orbit.diag.title')}
{t('orbit.diag.role')} {role ?? '—'}
{t('orbit.diag.hostTrack')} {state?.currentTrack?.trackId ?? '—'}
{t('orbit.diag.hostPos')} {hostPosSec != null ? `${hostPosSec}s · ${state?.isPlaying ? '▶' : '⏸'}` : '—'}
{role === 'guest' && ( <>
{t('orbit.diag.guestTrack')} {player.currentTrack?.id ?? '—'}
{t('orbit.diag.guestPos')} {guestPosSec}s · {player.isPlaying ? '▶' : '⏸'}
{t('orbit.diag.drift')} {driftMs != null ? `${(driftMs / 1000).toFixed(1)}s` : '—'}
)} {hostStateAgeMs != null && (
{t('orbit.diag.stateAge')} {(hostStateAgeMs / 1000).toFixed(1)}s
)}
{t('orbit.diag.eventLog', { count: events.length })}