diff --git a/CHANGELOG.md b/CHANGELOG.md index 62fd5859..fd8ceea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Added +### Orbit — in-app diagnostics popover with copyable event log + +**By [@Psychotoxical](https://github.com/Psychotoxical), prompted by reports from nzxl + RavingGrob, PR [#524](https://github.com/Psychotoxical/psysonic/pull/524)** + +* New **Activity-icon** button in the Orbit session bar opens a diagnostics popover. Live mini-display (role, host vs. guest track, position, drift, state-age) updates once a second; below it, a scrolling **event log textarea** is fed by a 200-entry in-memory ring buffer. +* **Copy** + **Clear** buttons. Copy drops formatted `[ISO] [scope] body` lines on the clipboard — paste straight into a Discord bug report. +* Instrumentation lands at every previously-silent decision point in the guest tick (`initial-sync`, `track-change` followed / diverged, `play-pause-flip`) plus host state pushes, so the "stopped after the first song" Orbit symptom is now diagnosable from the buffer alone. +* Events are also bridged to the existing `frontend_debug_log` command when **Settings → Logging** is on Debug, so power users still get the same data in `psysonic-logs-*.log` for offline triage. +* i18n: full `orbit.diag.*` namespace across all 8 locales (EN + DE native; ES / FR / NB / NL / RU / ZH first-pass, polish welcome). + ### Player Bar — album context menu on song title right-click **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#512](https://github.com/Psychotoxical/psysonic/pull/512)** diff --git a/src/components/OrbitDiagnosticsPopover.tsx b/src/components/OrbitDiagnosticsPopover.tsx new file mode 100644 index 00000000..caf666e1 --- /dev/null +++ b/src/components/OrbitDiagnosticsPopover.tsx @@ -0,0 +1,185 @@ +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/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 })} +
+ + +
+
+