feat(orbit): in-app diagnostics popover with copyable event log (#524)

* 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
This commit is contained in:
Frank Stellmacher
2026-05-09 21:49:17 +02:00
committed by GitHub
parent acadc1be34
commit a702a5dd5b
15 changed files with 610 additions and 3 deletions
+19 -1
View File
@@ -8,6 +8,7 @@ import {
writeOrbitHeartbeat,
} from '../utils/orbit';
import { orbitOutboxPlaylistName, estimateLivePosition, type OrbitState } from '../api/orbit';
import { pushOrbitEvent } from '../utils/orbitDiag';
/**
* Orbit — guest-side tick hook.
@@ -130,6 +131,7 @@ export function useOrbitGuest(): void {
// explicit `state.ended` branch does; the store still holds the last
// known state so the modal can render the host + session name copy.
// Outbox cleanup runs from the modal's OK handler via leaveOrbitSession.
pushOrbitEvent('pull', 'state read returned null — playlist gone, ending session');
useOrbitStore.getState().setPhase('ended');
return;
}
@@ -203,6 +205,12 @@ export function useOrbitGuest(): void {
const hostPlaying = state.isPlaying;
const last = lastAppliedRef.current;
pushOrbitEvent('pull', JSON.stringify({
host: { track: hostTrackId, playing: hostPlaying, posMs: state.positionMs, posAt: state.positionAt },
guest: { track: player.currentTrack?.id ?? null, playing: player.isPlaying, posSec: Math.round(player.currentTime ?? 0) },
last,
}));
if (!last) {
// Initial sync: only record `last` *after* syncToHost actually
// landed. If the first attempt loses the race (engine not ready,
@@ -211,9 +219,12 @@ export function useOrbitGuest(): void {
// failed sync set `last` anyway and the guest was stuck on their
// pre-join state until they clicked Catch Up.
if (hostTrackId) {
pushOrbitEvent('initial-sync', `attempting initial sync to ${hostTrackId} (hostPlaying=${hostPlaying})`);
const ok = await syncToHost(hostTrackId, state);
pushOrbitEvent('initial-sync', `result: ${ok ? 'success' : 'failed (will retry)'}`);
if (ok) lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
} else {
pushOrbitEvent('initial-sync', 'host has no current track yet, anchor only');
lastAppliedRef.current = { trackId: null, isPlaying: hostPlaying };
}
} else if (last.trackId !== hostTrackId) {
@@ -222,11 +233,15 @@ export function useOrbitGuest(): void {
// Guest is running their own show (typically: paused while host
// kept going). Do not load/start the host's new track — just
// track the host state so the catch-up prompt stays accurate.
pushOrbitEvent('track-change',
`host: ${last.trackId}${hostTrackId} BUT guest diverged (player.isPlaying=${player.isPlaying} ≠ last.isPlaying=${last.isPlaying}) — NOT loading new track`);
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
} else if (hostTrackId) {
pushOrbitEvent('track-change', `host: ${last.trackId}${hostTrackId}, guest in sync, following`);
void syncToHost(hostTrackId, state);
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
} else {
pushOrbitEvent('track-change', `host cleared current track, pausing guest`);
if (player.isPlaying) player.pause();
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
}
@@ -234,7 +249,10 @@ export function useOrbitGuest(): void {
// Only mirror when the guest hasn't diverged. We compare against the
// *last applied* host state, not the new one — divergence means the
// local player no longer matches what we last pushed in.
if (player.isPlaying === last.isPlaying) {
const localMatchesLast = player.isPlaying === last.isPlaying;
pushOrbitEvent('play-pause-flip',
`host: ${last.isPlaying}${hostPlaying}, guest matches last=${localMatchesLast} (will ${localMatchesLast ? 'mirror' : 'skip'})`);
if (localMatchesLast) {
if (hostPlaying) player.resume();
else player.pause();
}
+12 -1
View File
@@ -19,6 +19,7 @@ import {
} from '../api/orbit';
import { showToast } from '../utils/toast';
import i18n from '../i18n';
import { pushOrbitEvent } from '../utils/orbitDiag';
/**
* Orbit — host-side tick hook.
@@ -137,7 +138,17 @@ export function useOrbitHost(): void {
try {
await writeOrbitState(sessionPlaylistId, next);
lastPushedAtRef.current = Date.now();
} catch { /* best-effort; next tick retries */ }
pushOrbitEvent('host:push', JSON.stringify({
track: next.currentTrack?.trackId ?? null,
playing: next.isPlaying,
posMs: next.positionMs,
queueLen: next.playQueueTotal ?? next.playQueue?.length ?? 0,
guests: next.participants.length,
}));
} catch (e) {
pushOrbitEvent('host:push', `WRITE FAILED: ${String(e)}`);
/* best-effort; next tick retries */
}
};
/**