From a702a5dd5bffdd66c058a6ed5939fa526b824613 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 9 May 2026 21:49:17 +0200 Subject: [PATCH] feat(orbit): in-app diagnostics popover with copyable event log (#524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(orbit): in-app diagnostics popover with copyable event log Multiple users on Discord report Orbit guests stopping after the first song with no errors anywhere — Settings → Debug → Export Logs is too buried for non-technical reporters, and the relevant code branches have no logging at all (silent fail). This adds a one-click "Copy log" path right inside the Orbit session bar. The new Activity-icon button next to Help opens a popover with: - Live mini-display: role, host vs. guest track id + position, drift, age of the host's last state write — all updating once a second. - Scrolling event log textarea fed by an in-memory ring (200 events). - Copy + Clear buttons. Copy formats `[ISO] [scope] body` lines and drops them on the clipboard — paste straight into a Discord report. Instrumentation lands at the previously-silent decision points: - Guest pull tick: full snapshot of host vs. guest state on every read. - Each branch of the divergence detection in `useOrbitGuest.ts` logs which path it took and why (initial / track-change-followed / track-change-diverged / play-pause-flip), making the "stuck after first song" symptom diagnosable from the buffer alone. - Host pushes log track id, isPlaying, queue length, guest count. Events are also bridged to the existing `frontend_debug_log` Tauri 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 in all eight locales. EN + DE are native; ES / FR / NB / NL / RU / ZH are first-pass and may want a polish from native speakers later. * docs(changelog): add orbit diagnostics popover entry --- CHANGELOG.md | 10 ++ src/components/OrbitDiagnosticsPopover.tsx | 185 +++++++++++++++++++++ src/components/OrbitSessionBar.tsx | 23 ++- src/hooks/useOrbitGuest.ts | 20 ++- src/hooks/useOrbitHost.ts | 13 +- src/locales/de.ts | 21 +++ src/locales/en.ts | 21 +++ src/locales/es.ts | 21 +++ src/locales/fr.ts | 21 +++ src/locales/nb.ts | 21 +++ src/locales/nl.ts | 21 +++ src/locales/ru.ts | 21 +++ src/locales/zh.ts | 21 +++ src/styles/components.css | 98 +++++++++++ src/utils/orbitDiag.ts | 96 +++++++++++ 15 files changed, 610 insertions(+), 3 deletions(-) create mode 100644 src/components/OrbitDiagnosticsPopover.tsx create mode 100644 src/utils/orbitDiag.ts 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 })} +
+ + +
+
+