Compare commits

...

7 Commits

Author SHA1 Message Date
Psychotoxical 901d260bf1 fix(orbit): stop the orphan sweep from deleting live session playlists
The sweep's name regex kept the trailing __ inside the optional _from_ group,
so a bare session name (__psyorbit_<sid>__) never matched and was pruned as
'corrupt' before the reconnect-breadcrumb guard ran. This was latent while
quitting deleted the session; once sessions survive quit (reconnect), the next
launch's sweep deleted the live session, so the reconnect prompt found nothing
to resume. The trailing __ is now anchored outside the group so both session
and outbox names match. Adds a regression test.
2026-06-07 23:03:35 +02:00
Psychotoxical ebef8aa061 fix(orbit): show the reconnect prompt under React StrictMode
The startup preflight set its one-shot ref before the async ran, so under
StrictMode's dev double-invoke (mount -> cleanup -> mount) the cancelled first
run blocked setCandidate while the second mount short-circuited on the ref, and
the prompt never appeared in dev builds. The decided-flag is now set only after
the cancellation guard so the second (real) mount completes the check. Adds a
regression test that renders the modal under StrictMode.
2026-06-07 22:51:16 +02:00
Psychotoxical 9de56bf638 fix(orbit): keep the session alive on app quit so reconnect can offer it
Quitting ran endOrbitSession / leaveOrbitSession, which deleted the session
server-side and wiped the reconnect breadcrumb — so the restart prompt never
had anything to resume. App exit now leaves the session and breadcrumb intact:
quitting suspends the session rather than ending it. A deliberate end stays the
session bar's End button; switching servers still tears the session down.
2026-06-07 22:39:59 +02:00
Psychotoxical 842e041a6e test(orbit): cover reconnect breadcrumb and host resume 2026-06-07 22:28:51 +02:00
Psychotoxical 0de488335e feat(orbit): prompt to rejoin a session after an app restart
On launch, if a breadcrumb points at a still-alive session on the active
server, a modal offers a one-click rejoin with a 30s auto-rejoin countdown
(host resumes hosting, guest rejoins via the normal path). Declining, Escape,
or a failed attempt wipes the breadcrumb. Copy added across all nine locales.
2026-06-07 22:28:45 +02:00
Psychotoxical 2c95a3499c feat(orbit): persist a reconnect breadcrumb and add host-resume
Sessions stay in-memory only; a tiny localStorage breadcrumb (session id,
playlist ids, role, server) is written on host-start / guest-join and wiped on
any clean exit, so a restart can offer to rejoin. Adds resumeOrbitSessionAsHost,
which rebinds a still-live session as host and rebuilds the in-memory
merged-suggestion set from the restored player queue so resuming never
re-enqueues already-queued tracks. The app-start orphan sweep now skips the
breadcrumb's session so a pending reconnect can't be pruned mid-flight.
2026-06-07 22:28:39 +02:00
Psychotoxical de191e45d5 fix(orbit): keep diagnostics popover inside the window on any size
The diagnostics popover used a fixed 920px width and no height cap, so it
overflowed the left window edge on narrow windows and could drop below the
viewport on short ones. Width and position are now computed inline: width is
clamped to the viewport, the right-anchored popover slides inward so neither
edge spills off-screen, and height is capped to the space below the anchor
with the log textarea flex-shrinking to fit.
2026-06-07 22:02:44 +02:00
24 changed files with 952 additions and 27 deletions
+2
View File
@@ -23,6 +23,7 @@ import GlobalConfirmModal from '../components/GlobalConfirmModal';
import ThemeMigrationNotice from '../components/ThemeMigrationNotice';
import OrbitAccountPicker from '../components/OrbitAccountPicker';
import OrbitHelpModal from '../components/OrbitHelpModal';
import OrbitReconnectModal from '../components/OrbitReconnectModal';
import TooltipPortal from '../components/TooltipPortal';
import OverlayScrollArea from '../components/OverlayScrollArea';
import {
@@ -322,6 +323,7 @@ export function AppShell() {
<ThemeMigrationNotice />
<OrbitAccountPicker />
<OrbitHelpModal />
<OrbitReconnectModal />
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
<AppUpdater />
</div>
+24 -8
View File
@@ -66,14 +66,30 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
}, [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' };
let style: React.CSSProperties = { display: 'none' };
if (anchor) {
const vw = window.innerWidth;
// Compact, viewport-bounded width (same scale as the other Orbit popovers).
const width = Math.min(560, vw - 16);
// Anchor under the trigger, but slide inward so the right-anchored popover
// never spills off either window edge — on a narrow window the left edge
// would otherwise drop off-screen.
const right = Math.min(
Math.max(8, vw - anchor.right),
Math.max(8, vw - width - 8),
);
style = {
position: 'fixed',
top: anchor.bottom + 12,
right,
width,
// Cap height to the space below the anchor (minus a small margin); the
// log textarea flex-shrinks to fit. Re-read every render (the 1s
// mini-display tick keeps width/height fresh after a window resize).
maxHeight: `calc(100vh - ${Math.round(anchor.bottom + 24)}px)`,
zIndex: 9999,
};
}
// ── Live mini-display data ────────────────────────────────────────────
const role = useOrbitStore(s => s.role);
+118
View File
@@ -0,0 +1,118 @@
import { StrictMode } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { makeInitialOrbitState } from '../api/orbit';
const { authState, orbitState, orbitApi } = vi.hoisted(() => ({
authState: { isLoggedIn: true, activeServerId: 'srv-1' as string | null, username: 'bob' as string | undefined },
orbitState: { role: null as 'host' | 'guest' | null },
orbitApi: {
readOrbitLastSession: vi.fn(),
findSessionPlaylistId: vi.fn(),
readOrbitState: vi.fn(),
clearOrbitLastSession: vi.fn(),
resumeOrbitSessionAsHost: vi.fn(),
joinOrbitSession: vi.fn(),
},
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
vi.mock('../store/authStore', () => {
const hook = (sel: (s: typeof authState) => unknown) => sel(authState);
(hook as unknown as { getState: () => unknown }).getState = () => ({
...authState,
getActiveServer: () => (authState.username ? { username: authState.username } : undefined),
});
return { useAuthStore: hook };
});
vi.mock('../store/orbitStore', () => {
const hook = (sel: (s: typeof orbitState) => unknown) => sel(orbitState);
(hook as unknown as { getState: () => unknown }).getState = () => orbitState;
return { useOrbitStore: hook };
});
vi.mock('../utils/ui/toast', () => ({ showToast: vi.fn() }));
vi.mock('../utils/orbit', () => ({
...orbitApi,
ORBIT_RECONNECT_COUNTDOWN_S: 30,
ORBIT_RECONNECT_MAX_AGE_MS: 30 * 60_000,
}));
import OrbitReconnectModal from './OrbitReconnectModal';
const hostBreadcrumb = {
sid: 'feedface',
sessionPlaylistId: 'pl-1',
outboxPlaylistId: 'ob-1',
role: 'host' as const,
sessionName: 'Night Run',
hostUsername: 'bob',
serverId: 'srv-1',
savedAt: 1,
};
beforeEach(() => {
vi.clearAllMocks();
authState.isLoggedIn = true;
authState.activeServerId = 'srv-1';
authState.username = 'bob';
orbitState.role = null;
orbitApi.readOrbitLastSession.mockReturnValue(hostBreadcrumb);
orbitApi.findSessionPlaylistId.mockResolvedValue('pl-1');
orbitApi.readOrbitState.mockResolvedValue(
makeInitialOrbitState({ sid: 'feedface', host: 'bob', name: 'Night Run' }),
);
});
describe('OrbitReconnectModal', () => {
// Regression: StrictMode double-invokes effects (mount → cleanup → mount).
// The old one-shot guard let the cancelled first run block the second, so the
// prompt never appeared in dev builds. This must survive the double-invoke.
it('shows the reconnect prompt under StrictMode for a live host session', async () => {
render(
<StrictMode>
<OrbitReconnectModal />
</StrictMode>,
);
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
expect(orbitApi.clearOrbitLastSession).not.toHaveBeenCalled();
});
it('does not prompt when the breadcrumb is for a different server', async () => {
authState.activeServerId = 'other-server';
render(
<StrictMode>
<OrbitReconnectModal />
</StrictMode>,
);
await Promise.resolve();
await Promise.resolve();
expect(screen.queryByRole('dialog')).toBeNull();
expect(orbitApi.findSessionPlaylistId).not.toHaveBeenCalled();
});
it('wipes the breadcrumb and stays silent when the session is gone', async () => {
orbitApi.findSessionPlaylistId.mockResolvedValue(null);
render(
<StrictMode>
<OrbitReconnectModal />
</StrictMode>,
);
await waitFor(() => expect(orbitApi.clearOrbitLastSession).toHaveBeenCalled());
expect(screen.queryByRole('dialog')).toBeNull();
});
it('does not prompt while already bound to a session', async () => {
orbitState.role = 'host';
render(
<StrictMode>
<OrbitReconnectModal />
</StrictMode>,
);
await Promise.resolve();
await Promise.resolve();
expect(screen.queryByRole('dialog')).toBeNull();
expect(orbitApi.findSessionPlaylistId).not.toHaveBeenCalled();
});
});
+157
View File
@@ -0,0 +1,157 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import { showToast } from '../utils/ui/toast';
import {
clearOrbitLastSession,
findSessionPlaylistId,
joinOrbitSession,
ORBIT_RECONNECT_COUNTDOWN_S,
ORBIT_RECONNECT_MAX_AGE_MS,
readOrbitLastSession,
readOrbitState,
resumeOrbitSessionAsHost,
type OrbitLastSession,
} from '../utils/orbit';
/**
* Orbit — reconnect prompt shown at startup when the app was restarted while a
* session was active. The in-memory Orbit store is gone after a restart, but
* the `lastSession` breadcrumb survives; this verifies the session is still
* alive on the server and offers a one-click rejoin with a countdown that
* auto-rejoins when it reaches zero.
*
* Host breadcrumbs resume hosting (`resumeOrbitSessionAsHost`); guest
* breadcrumbs rejoin via the normal `joinOrbitSession` path. Declining /
* Escape / a failed attempt wipes the breadcrumb so it isn't offered again.
*/
export default function OrbitReconnectModal() {
const { t } = useTranslation();
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const activeServerId = useAuthStore(s => s.activeServerId);
const orbitRole = useOrbitStore(s => s.role);
const [candidate, setCandidate] = useState<OrbitLastSession | null>(null);
const [secondsLeft, setSecondsLeft] = useState(ORBIT_RECONNECT_COUNTDOWN_S);
const [busy, setBusy] = useState(false);
const decidedRef = useRef(false);
const firedRef = useRef(false);
// One-shot startup preflight: is there a live session worth offering?
//
// StrictMode-safe: `decidedRef` is set only *after* the `cancelled` guard
// inside the async, never up front. React's dev double-invoke (mount →
// cleanup → mount) cancels the first run before it can decide, so the second
// (real) mount still runs the check to completion. A network error leaves
// `decidedRef` false so a later dependency change retries.
useEffect(() => {
if (decidedRef.current) return;
if (!isLoggedIn || !activeServerId || orbitRole !== null) return;
const rec = readOrbitLastSession();
if (!rec || rec.serverId !== activeServerId) return; // none / different server
let cancelled = false;
void (async () => {
try {
const sessionPlaylistId = await findSessionPlaylistId(rec.sid);
if (cancelled) return;
if (!sessionPlaylistId) { decidedRef.current = true; clearOrbitLastSession(); return; }
const state = await readOrbitState(sessionPlaylistId);
if (cancelled) return;
if (!state || state.ended) { decidedRef.current = true; clearOrbitLastSession(); return; }
// Too long since the last host snapshot → treat as dead, don't offer.
if (Date.now() - (state.positionAt ?? 0) > ORBIT_RECONNECT_MAX_AGE_MS) {
decidedRef.current = true; clearOrbitLastSession(); return;
}
// A host breadcrumb only resumes if we're still the session's host.
if (rec.role === 'host' && state.host !== useAuthStore.getState().getActiveServer()?.username) {
decidedRef.current = true; clearOrbitLastSession(); return;
}
decidedRef.current = true;
setCandidate(rec);
} catch {
/* network hiccup — keep the breadcrumb + retry on the next dep change */
}
})();
return () => { cancelled = true; };
}, [isLoggedIn, activeServerId, orbitRole]);
const doReconnect = useCallback(async () => {
if (firedRef.current) return; // guard countdown + manual click racing
const rec = candidate;
if (!rec) return;
firedRef.current = true;
setBusy(true);
try {
if (rec.role === 'host') await resumeOrbitSessionAsHost(rec.sid);
else await joinOrbitSession(rec.sid);
setCandidate(null); // success → store now bound, modal hides
} catch {
clearOrbitLastSession();
showToast(t('orbit.reconnect.failed'), 4000, 'error');
setCandidate(null);
} finally {
setBusy(false);
}
}, [candidate, t]);
const stayOut = useCallback(() => {
clearOrbitLastSession();
setCandidate(null);
}, []);
// Countdown → auto-rejoin at zero. Paused while a reconnect is in flight.
useEffect(() => {
if (!candidate || busy) return;
if (secondsLeft <= 0) { void doReconnect(); return; }
const id = window.setTimeout(() => setSecondsLeft(s => s - 1), 1000);
return () => window.clearTimeout(id);
}, [candidate, busy, secondsLeft, doReconnect]);
// Enter → rejoin now; Escape → stay out (explicit dismissal, no auto-rejoin).
useEffect(() => {
if (!candidate) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); void doReconnect(); }
else if (e.key === 'Escape') { e.preventDefault(); stayOut(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [candidate, doReconnect, stayOut]);
if (!candidate) return null;
const body = candidate.role === 'host'
? t('orbit.reconnect.bodyHost', { name: candidate.sessionName })
: t('orbit.reconnect.bodyGuest', { host: candidate.hostUsername, name: candidate.sessionName });
return createPortal(
<div
className="modal-overlay orbit-exit-overlay"
onClick={e => { if (e.target === e.currentTarget) stayOut(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-reconnect-title"
>
<div className="modal-content orbit-exit-modal">
<h3 id="orbit-reconnect-title" className="orbit-exit-modal__title">{t('orbit.reconnect.title')}</h3>
<p className="orbit-exit-modal__body">{body}</p>
{!busy && (
<p className="orbit-reconnect-countdown">{t('orbit.reconnect.autoIn', { seconds: secondsLeft })}</p>
)}
<div className="orbit-exit-modal__actions">
<button type="button" className="btn btn-ghost" onClick={stayOut} disabled={busy}>
{t('orbit.reconnect.stayOut')}
</button>
<button type="button" className="btn btn-primary" onClick={() => void doReconnect()} disabled={busy} autoFocus>
{busy ? t('orbit.reconnect.reconnecting') : t('orbit.reconnect.rejoin')}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -8,8 +8,6 @@ import { playListenSessionFinalize } from '../../store/playListenSession';
import { getPlaybackProgressSnapshot } from '../../store/playbackProgress';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
import { useOrbitStore } from '../../store/orbitStore';
import { endOrbitSession, leaveOrbitSession } from '../../utils/orbit';
import {
canRunShortcutActionInMiniWindow,
executeRuntimeAction,
@@ -116,14 +114,12 @@ export function useMediaAndWindowBridge(navigate: NavigateFunction) {
flushPlayQueuePosition(),
new Promise(r => setTimeout(r, 1500)),
]);
const role = useOrbitStore.getState().role;
if (role === 'host' || role === 'guest') {
const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession();
await Promise.race([
teardown.catch(() => {}),
new Promise(r => setTimeout(r, 1500)),
]);
}
// Orbit: do NOT tear the session down on quit. Leave it alive on the
// server (and the breadcrumb in place) so the next launch can offer to
// reconnect — the host resumes hosting, a guest rejoins. Deliberately
// ending a session is the session bar's "End" button; quitting only
// suspends it. Orphaned playlists are reaped by the app-start sweep
// once the session goes stale.
await invoke('exit_app');
};
+10
View File
@@ -1,4 +1,14 @@
export const orbit = {
reconnect: {
title: 'Wieder in deine Orbit-Session?',
bodyHost: 'Du hast „{{name}}" gehostet. Dort weitermachen, wo du aufgehört hast?',
bodyGuest: '{{host}} hostet „{{name}}". Wieder beitreten?',
autoIn: 'Automatisch in {{seconds}}s',
rejoin: 'Wieder rein',
stayOut: 'Draußen bleiben',
reconnecting: 'Trete bei…',
failed: 'Wiedereinstieg fehlgeschlagen — die Session ist vielleicht beendet.',
},
triggerLabel: 'Orbit',
triggerTooltip: 'Gemeinsame Hör-Session starten oder beitreten',
launchCreate: 'Session erstellen',
+10
View File
@@ -1,4 +1,14 @@
export const orbit = {
reconnect: {
title: 'Rejoin your Orbit session?',
bodyHost: 'You were hosting "{{name}}". Pick the session back up where you left off?',
bodyGuest: '{{host}} is hosting "{{name}}". Rejoin the session?',
autoIn: 'Auto-rejoin in {{seconds}}s',
rejoin: 'Rejoin',
stayOut: 'Stay out',
reconnecting: 'Rejoining…',
failed: "Couldn't rejoin — the session may have ended.",
},
triggerLabel: 'Orbit',
triggerTooltip: 'Start or join a shared listening session',
launchCreate: 'Create a session',
+10
View File
@@ -1,4 +1,14 @@
export const orbit = {
reconnect: {
title: '¿Volver a tu sesión de Orbit?',
bodyHost: 'Estabas alojando «{{name}}». ¿Retomar la sesión donde la dejaste?',
bodyGuest: '{{host}} está alojando «{{name}}». ¿Volver a unirte?',
autoIn: 'Reconexión automática en {{seconds}} s',
rejoin: 'Volver a unirse',
stayOut: 'Quedarse fuera',
reconnecting: 'Uniéndose…',
failed: 'No se pudo volver a unir: puede que la sesión haya terminado.',
},
triggerLabel: 'Orbit',
triggerTooltip: 'Iniciar o unirse a una sesión de escucha compartida',
launchCreate: 'Crear una sesión',
+10
View File
@@ -1,4 +1,14 @@
export const orbit = {
reconnect: {
title: 'Rejoindre votre session Orbit ?',
bodyHost: 'Vous hébergiez « {{name}} ». Reprendre la session là où vous l\'avez laissée ?',
bodyGuest: '{{host}} héberge « {{name}} ». Rejoindre la session ?',
autoIn: 'Reconnexion auto dans {{seconds}} s',
rejoin: 'Rejoindre',
stayOut: 'Rester en dehors',
reconnecting: 'Connexion…',
failed: 'Impossible de rejoindre — la session est peut-être terminée.',
},
triggerLabel: 'Orbit',
triggerTooltip: 'Démarrer ou rejoindre une session d\'écoute partagée',
launchCreate: 'Créer une session',
+10
View File
@@ -1,4 +1,14 @@
export const orbit = {
reconnect: {
title: 'Bli med i Orbit-økten igjen?',
bodyHost: 'Du var vert for «{{name}}». Fortsette økten der du slapp?',
bodyGuest: '{{host}} er vert for «{{name}}». Bli med igjen?',
autoIn: 'Kobler til automatisk om {{seconds}} s',
rejoin: 'Bli med igjen',
stayOut: 'Hold deg utenfor',
reconnecting: 'Kobler til…',
failed: 'Kunne ikke bli med igjen økten kan være avsluttet.',
},
triggerLabel: 'Orbit',
triggerTooltip: 'Start eller bli med i en delt lytteøkt',
launchCreate: 'Opprett en økt',
+10
View File
@@ -1,4 +1,14 @@
export const orbit = {
reconnect: {
title: 'Weer in je Orbit-sessie?',
bodyHost: 'Je hostte "{{name}}". De sessie hervatten waar je was gebleven?',
bodyGuest: '{{host}} host "{{name}}". Weer deelnemen?',
autoIn: 'Automatisch over {{seconds}} s',
rejoin: 'Opnieuw deelnemen',
stayOut: 'Erbuiten blijven',
reconnecting: 'Deelnemen…',
failed: 'Kon niet opnieuw deelnemen — de sessie is mogelijk beëindigd.',
},
triggerLabel: 'Orbit',
triggerTooltip: 'Een gedeelde luistersessie starten of eraan deelnemen',
launchCreate: 'Sessie aanmaken',
+10
View File
@@ -1,4 +1,14 @@
export const orbit = {
reconnect: {
title: 'Reintri în sesiunea Orbit?',
bodyHost: 'Găzduiai „{{name}}". Reiei sesiunea de unde ai rămas?',
bodyGuest: '{{host}} găzduiește „{{name}}". Reintri în sesiune?',
autoIn: 'Reconectare automată în {{seconds}} s',
rejoin: 'Reintră',
stayOut: 'Rămâi afară',
reconnecting: 'Se reconectează…',
failed: 'Nu s-a putut reintra — sesiunea s-ar putea să se fi încheiat.',
},
triggerLabel: 'Orbit',
triggerTooltip: 'Pornește sau intră în o sesiune de ascultare distribuită',
launchCreate: 'Crează o sesiune',
+10
View File
@@ -1,4 +1,14 @@
export const orbit = {
reconnect: {
title: 'Вернуться в сессию Orbit?',
bodyHost: 'Вы вели «{{name}}». Продолжить с того места, где остановились?',
bodyGuest: '{{host}} ведёт «{{name}}». Снова присоединиться?',
autoIn: 'Автоматически через {{seconds}} с',
rejoin: 'Вернуться',
stayOut: 'Остаться',
reconnecting: 'Подключение…',
failed: 'Не удалось вернуться — возможно, сессия завершена.',
},
triggerLabel: 'Orbit',
triggerTooltip: 'Начать или присоединиться к совместной сессии прослушивания',
launchCreate: 'Создать сессию',
+10
View File
@@ -1,4 +1,14 @@
export const orbit = {
reconnect: {
title: '重新加入你的 Orbit 会话?',
bodyHost: '你之前在主持「{{name}}」。从上次离开的地方继续会话吗?',
bodyGuest: '{{host}} 正在主持「{{name}}」。重新加入会话吗?',
autoIn: '{{seconds}} 秒后自动重连',
rejoin: '重新加入',
stayOut: '不加入',
reconnecting: '正在加入…',
failed: '无法重新加入——会话可能已结束。',
},
triggerLabel: 'Orbit',
triggerTooltip: '开始或加入共享收听会话',
launchCreate: '创建会话',
@@ -400,6 +400,16 @@
.orbit-exit-modal__actions {
display: flex;
justify-content: center;
gap: 10px;
}
/* Reconnect prompt — auto-rejoin countdown line above the actions. */
.orbit-reconnect-countdown {
margin: 0 0 16px;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
text-align: center;
}
/* ── Topbar "start Orbit" trigger — same form as Live, accent-tinted ── */
@@ -856,8 +866,9 @@
/* ── Diagnostics popover ──────────────────────────────────────────────── */
.orbit-diag-pop {
width: 920px;
max-width: calc(100vw - 24px);
/* Width + position are computed inline (see OrbitDiagnosticsPopover.tsx) so
the right-anchored popover stays inside the window on any size. Long log
lines scroll horizontally inside the textarea instead of widening this. */
padding: 14px;
background: var(--bg-card);
border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1));
@@ -932,7 +943,10 @@
}
.orbit-diag-pop__log {
width: 100%;
height: 220px;
/* Prefer 220px, but flex-shrink (down to min-height) when the popover is
height-capped on short windows so it never spills past the viewport. */
flex: 1 1 220px;
min-height: 80px;
resize: vertical;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 10.5px;
+8
View File
@@ -22,6 +22,8 @@
export {
ORBIT_HEARTBEAT_ALIVE_MS,
ORBIT_ORPHAN_TTL_MS,
ORBIT_RECONNECT_COUNTDOWN_S,
ORBIT_RECONNECT_MAX_AGE_MS,
ORBIT_REMOVED_TTL_MS,
ORBIT_SHUFFLE_INTERVAL_MS,
} from './orbit/constants';
@@ -53,11 +55,17 @@ export {
export {
endOrbitSession,
hostEnqueueToOrbit,
resumeOrbitSessionAsHost,
startOrbitSession,
triggerOrbitShuffleNow,
updateOrbitSettings,
type StartOrbitArgs,
} from './orbit/host';
export {
clearOrbitLastSession,
readOrbitLastSession,
type OrbitLastSession,
} from './orbit/lastSession';
export {
kickOrbitParticipant,
removeOrbitParticipant,
+76
View File
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { makeInitialOrbitState } from '../../api/orbit';
const playlists = vi.hoisted(() => ({ getPlaylists: vi.fn(), deletePlaylist: vi.fn() }));
const lastSession = vi.hoisted(() => ({ readOrbitLastSessionSid: vi.fn() }));
const orbitStoreState = vi.hoisted(() => ({ sessionId: null as string | null }));
vi.mock('../../api/subsonicPlaylists', () => playlists);
vi.mock('../../store/authStore', () => ({
useAuthStore: { getState: () => ({ getActiveServer: () => ({ username: 'bob' }) }) },
}));
vi.mock('../../store/orbitStore', () => ({
useOrbitStore: { getState: () => orbitStoreState },
}));
vi.mock('./lastSession', () => lastSession);
import { cleanupOrphanedOrbitPlaylists } from './cleanup';
function sessionComment(sid: string, ageMs = 0): string {
const s = makeInitialOrbitState({ sid, host: 'bob', name: 'X' });
return JSON.stringify({ ...s, positionAt: Date.now() - ageMs });
}
const pl = (name: string, comment: string | undefined, id = name) => ({
id, name, owner: 'bob', comment, changed: undefined as string | undefined,
});
beforeEach(() => {
vi.clearAllMocks();
orbitStoreState.sessionId = null;
lastSession.readOrbitLastSessionSid.mockReturnValue(null);
playlists.deletePlaylist.mockResolvedValue(undefined);
});
describe('cleanupOrphanedOrbitPlaylists', () => {
it('does not prune a live session protected by the reconnect breadcrumb', async () => {
// Regression: the bare session name (`__psyorbit_<sid>__`) must match the
// regex so the sid-skip is reached. A mis-anchored regex treated it as
// corrupt and deleted it before the breadcrumb guard — killing reconnect.
lastSession.readOrbitLastSessionSid.mockReturnValue('aaaa1111');
playlists.getPlaylists.mockResolvedValue([
pl('__psyorbit_aaaa1111__', sessionComment('aaaa1111', 60 * 60_000)),
pl('__psyorbit_aaaa1111_from_bob__', JSON.stringify({ ts: Date.now() - 60 * 60_000 })),
]);
const deleted = await cleanupOrphanedOrbitPlaylists();
expect(deleted).toBe(0);
expect(playlists.deletePlaylist).not.toHaveBeenCalled();
});
it('recognises a fresh session playlist instead of pruning the bare name', async () => {
playlists.getPlaylists.mockResolvedValue([
pl('__psyorbit_bbbb2222__', sessionComment('bbbb2222', 0)),
]);
const deleted = await cleanupOrphanedOrbitPlaylists();
expect(deleted).toBe(0);
expect(playlists.deletePlaylist).not.toHaveBeenCalled();
});
it('prunes a stale, unprotected session', async () => {
playlists.getPlaylists.mockResolvedValue([
pl('__psyorbit_cccc3333__', sessionComment('cccc3333', 60 * 60_000)),
]);
const deleted = await cleanupOrphanedOrbitPlaylists();
expect(deleted).toBe(1);
expect(playlists.deletePlaylist).toHaveBeenCalledWith('__psyorbit_cccc3333__');
});
it('prunes a corrupt / unrecognised orbit name', async () => {
playlists.getPlaylists.mockResolvedValue([
pl('__psyorbit_not-hex-no-suffix', undefined),
]);
const deleted = await cleanupOrphanedOrbitPlaylists();
expect(deleted).toBe(1);
});
});
+13 -2
View File
@@ -3,6 +3,7 @@ import { useAuthStore } from '../../store/authStore';
import { useOrbitStore } from '../../store/orbitStore';
import { ORBIT_PLAYLIST_PREFIX, parseOrbitState } from '../../api/orbit';
import { ORBIT_ORPHAN_TTL_MS } from './constants';
import { readOrbitLastSessionSid } from './lastSession';
/**
* App-start sweep: delete our own __psyorbit_* playlists that no longer
@@ -24,8 +25,18 @@ export async function cleanupOrphanedOrbitPlaylists(): Promise<number> {
const now = Date.now();
const TTL = ORBIT_ORPHAN_TTL_MS;
const currentSid = useOrbitStore.getState().sessionId;
// Protect a session the reconnect prompt is about to offer (the local store
// is empty right after a restart, so `currentSid` alone wouldn't cover it).
// Order-independent: if reconnect declines/fails, the breadcrumb is wiped and
// the next sweep prunes it.
const reconnectSid = readOrbitLastSessionSid();
const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+__)?$`);
// Matches both a session playlist (`__psyorbit_<sid>__`) and an outbox
// (`__psyorbit_<sid>_from_<user>__`). The trailing `__` must live outside the
// optional `_from_…` group, otherwise a bare session name fails to match and
// gets pruned as "corrupt" — which would delete a live session a restart is
// about to reconnect to.
const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+)?__$`);
let deleted = 0;
for (const p of all) {
@@ -41,7 +52,7 @@ export async function cleanupOrphanedOrbitPlaylists(): Promise<number> {
}
const sid = match[1];
const isOutbox = !!match[2];
if (sid === currentSid) continue;
if (sid === currentSid || sid === reconnectSid) continue;
let timestamp = 0;
let ended = false;
+11
View File
@@ -26,6 +26,17 @@ export const ORBIT_ORPHAN_TTL_MS = 5 * 60_000;
*/
export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000;
/**
* Reconnect prompt (after an app restart mid-session): how long the countdown
* runs before auto-rejoining, and how stale the live session may be before we
* stop offering a reconnect at all. The age window is generous (a restart
* after a short break should still offer) but bounded so a long-dead session
* isn't resurrected; the app-start orphan sweep protects the breadcrumb's
* session for this whole window.
*/
export const ORBIT_RECONNECT_COUNTDOWN_S = 30;
export const ORBIT_RECONNECT_MAX_AGE_MS = 30 * 60_000;
/**
* How long a soft-`removed` marker stays in the state blob. Long enough for
* the affected guest's 2.5 s read tick to surface the modal even after a
+7
View File
@@ -11,6 +11,7 @@ import {
} from '../../api/orbit';
import { suggestionKey } from './helpers';
import { findSessionPlaylistId, readOrbitState, writeOrbitHeartbeat } from './remote';
import { clearOrbitLastSession, persistCurrentOrbitSession } from './lastSession';
export class OrbitJoinError extends Error {
constructor(
@@ -96,6 +97,10 @@ export async function joinOrbitSession(sid: string): Promise<OrbitState> {
joinedAt: Date.now(),
});
// Drop a restart-survival breadcrumb so a crash/force-quit can offer to
// rejoin on next launch.
persistCurrentOrbitSession();
return state;
} catch (err) {
// Best-effort cleanup.
@@ -120,6 +125,8 @@ export async function leaveOrbitSession(): Promise<void> {
try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ }
}
// Clean exit → drop the restart breadcrumb.
clearOrbitLastSession();
useOrbitStore.getState().reset();
}
+81 -4
View File
@@ -1,4 +1,4 @@
import { createPlaylist, deletePlaylist } from '../../api/subsonicPlaylists';
import { createPlaylist, deletePlaylist, getPlaylists } from '../../api/subsonicPlaylists';
import { getSong } from '../../api/subsonicLibrary';
import { songToTrack } from '../playback/songToTrack';
import { useAuthStore } from '../../store/authStore';
@@ -13,8 +13,9 @@ import {
type OrbitSettings,
type OrbitState,
} from '../../api/orbit';
import { generateSessionId } from './helpers';
import { writeOrbitHeartbeat, writeOrbitState } from './remote';
import { generateSessionId, suggestionKey } from './helpers';
import { findSessionPlaylistId, readOrbitState, writeOrbitHeartbeat, writeOrbitState } from './remote';
import { clearOrbitLastSession, persistCurrentOrbitSession } from './lastSession';
export interface StartOrbitArgs {
/** Human-readable name the host chose. */
@@ -89,6 +90,10 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitStat
joinedAt: Date.now(),
});
// Drop a restart-survival breadcrumb so a crash/force-quit can offer
// to resume hosting on next launch.
persistCurrentOrbitSession();
return state;
} catch (err) {
// Best-effort cleanup of anything we managed to create before the failure.
@@ -99,6 +104,77 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitStat
}
}
/**
* Host: resume hosting an existing session after an app restart.
*
* Unlike {@link startOrbitSession} this creates no playlists — it re-binds the
* local store to a session that's still alive on the server. The host's own
* session + outbox playlists survive a quick restart (the orphan sweep only
* prunes them after `ORBIT_ORPHAN_TTL_MS`), and the play queue is restored from
* the persisted player store, so playback continues where it left off.
*
* Returns the re-read state on success. Throws on any gate failure (no user /
* not the host / session gone / ended) — the caller wipes the breadcrumb and
* stays idle.
*/
export async function resumeOrbitSessionAsHost(sid: string): Promise<OrbitState> {
const server = useAuthStore.getState().getActiveServer();
const username = server?.username;
if (!username) throw new Error('No active Navidrome server / user');
const store = useOrbitStore.getState();
if (store.phase !== 'idle') throw new Error(`Cannot resume while phase is ${store.phase}`);
store.setPhase('starting');
try {
// 1) Session must still exist, be readable, not ended — and we must be its host.
const sessionPlaylistId = await findSessionPlaylistId(sid);
if (!sessionPlaylistId) throw new Error(`Session ${sid} not found on server`);
const state = await readOrbitState(sessionPlaylistId);
if (!state) throw new Error(`Session ${sid} has no valid state`);
if (state.ended) throw new Error(`Session ${sid} has ended`);
if (state.host !== username) throw new Error(`Not the host of session ${sid}`);
// 2) Re-locate (or recreate) our own outbox and refresh its heartbeat.
const outboxName = orbitOutboxPlaylistName(sid, username);
const existing = (await getPlaylists(true).catch(() => [])).find(p => p.name === outboxName);
const outboxPlaylistId = existing ? existing.id : (await createPlaylist(outboxName)).id;
await writeOrbitHeartbeat(outboxPlaylistId, outboxName);
// 3) Rebuild the in-memory merged-suggestion set (lost on restart) from the
// restored player queue so the resumed host tick won't re-enqueue tracks
// that are already queued. Anything already in the queue / current track
// counts as "already handled"; genuinely-pending suggestions stay pending.
const player = usePlayerStore.getState();
const inQueue = new Set<string>(player.queueItems.map(r => r.trackId));
if (player.currentTrack?.id) inQueue.add(player.currentTrack.id);
const mergedSuggestionKeys = state.queue
.filter(q => inQueue.has(q.trackId))
.map(suggestionKey);
// 4) Re-bind the store as host; the already-mounted host tick takes over.
useOrbitStore.setState({
role: 'host',
sessionId: sid,
sessionPlaylistId,
outboxPlaylistId,
phase: 'active',
state,
errorMessage: null,
joinedAt: Date.now(),
mergedSuggestionKeys,
declinedSuggestionKeys: [],
pendingSuggestions: [],
});
persistCurrentOrbitSession();
return state;
} catch (err) {
useOrbitStore.getState().setPhase('idle');
throw err;
}
}
/**
* Host: end the session cleanly.
*
@@ -124,7 +200,8 @@ export async function endOrbitSession(): Promise<void> {
if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ } }
if (sessionPlaylistId) { try { await deletePlaylist(sessionPlaylistId); } catch { /* best-effort */ } }
// 3) Local teardown.
// 3) Local teardown. Clean exit → drop the restart breadcrumb.
clearOrbitLastSession();
useOrbitStore.getState().reset();
}
+121
View File
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { makeInitialOrbitState, type OrbitQueueItem, type OrbitState } from '../../api/orbit';
import { suggestionKey } from './helpers';
const { authState, playerState } = vi.hoisted(() => ({
authState: { username: 'bob' as string | undefined },
playerState: {
queueItems: [] as { serverId: string; trackId: string }[],
currentTrack: null as { id: string } | null,
},
}));
const remote = vi.hoisted(() => ({
findSessionPlaylistId: vi.fn(),
readOrbitState: vi.fn(),
writeOrbitHeartbeat: vi.fn(),
writeOrbitState: vi.fn(),
}));
const playlists = vi.hoisted(() => ({
createPlaylist: vi.fn(),
getPlaylists: vi.fn(),
deletePlaylist: vi.fn(),
}));
vi.mock('./remote', () => remote);
vi.mock('../../api/subsonicPlaylists', () => playlists);
vi.mock('../../api/subsonicLibrary', () => ({ getSong: vi.fn() }));
vi.mock('../playback/songToTrack', () => ({ songToTrack: vi.fn() }));
vi.mock('./lastSession', () => ({
persistCurrentOrbitSession: vi.fn(),
clearOrbitLastSession: vi.fn(),
}));
vi.mock('../../store/authStore', () => ({
useAuthStore: {
getState: () => ({
getActiveServer: () => (authState.username ? { username: authState.username } : undefined),
}),
},
}));
vi.mock('../../store/playerStore', () => ({
usePlayerStore: { getState: () => playerState },
}));
import { resumeOrbitSessionAsHost } from './host';
import { useOrbitStore } from '../../store/orbitStore';
function buildState(over: Partial<OrbitState> = {}): OrbitState {
return { ...makeInitialOrbitState({ sid: 'feedface', host: 'bob', name: 'Night Run' }), ...over };
}
const q = (trackId: string, addedBy = 'carol', addedAt = 1000): OrbitQueueItem => ({ trackId, addedBy, addedAt });
beforeEach(() => {
vi.clearAllMocks();
useOrbitStore.getState().reset();
authState.username = 'bob';
playerState.queueItems = [];
playerState.currentTrack = null;
remote.findSessionPlaylistId.mockResolvedValue('pl-1');
remote.writeOrbitHeartbeat.mockResolvedValue(undefined);
playlists.getPlaylists.mockResolvedValue([]);
playlists.createPlaylist.mockResolvedValue({ id: 'ob-1' });
});
describe('resumeOrbitSessionAsHost', () => {
it('rebinds the store as host and marks already-queued suggestions as merged', async () => {
const q1 = q('t1');
const q2 = q('t2', 'dave', 2000);
const q3 = q('t3', 'erin', 3000);
remote.readOrbitState.mockResolvedValue(buildState({ queue: [q1, q2, q3] }));
playerState.queueItems = [{ serverId: 's', trackId: 't1' }];
playerState.currentTrack = { id: 't2' };
const state = await resumeOrbitSessionAsHost('feedface');
const bound = useOrbitStore.getState();
expect(bound.role).toBe('host');
expect(bound.sessionId).toBe('feedface');
expect(bound.phase).toBe('active');
// t1 (in queue) + t2 (current track) count as already handled; t3 does not.
expect(bound.mergedSuggestionKeys).toEqual([suggestionKey(q1), suggestionKey(q2)]);
expect(bound.mergedSuggestionKeys).not.toContain(suggestionKey(q3));
expect(state.host).toBe('bob');
expect(remote.writeOrbitHeartbeat).toHaveBeenCalledTimes(1);
});
it('reuses an existing outbox instead of creating a duplicate', async () => {
remote.readOrbitState.mockResolvedValue(buildState());
playlists.getPlaylists.mockResolvedValue([
{ id: 'ob-existing', name: '__psyorbit_feedface_from_bob__' },
]);
await resumeOrbitSessionAsHost('feedface');
expect(playlists.createPlaylist).not.toHaveBeenCalled();
expect(useOrbitStore.getState().outboxPlaylistId).toBe('ob-existing');
});
it('throws and stays idle when the session is gone', async () => {
remote.findSessionPlaylistId.mockResolvedValue(null);
await expect(resumeOrbitSessionAsHost('feedface')).rejects.toThrow();
expect(useOrbitStore.getState().role).toBeNull();
expect(useOrbitStore.getState().phase).toBe('idle');
});
it('throws when the session has ended', async () => {
remote.readOrbitState.mockResolvedValue(buildState({ ended: true }));
await expect(resumeOrbitSessionAsHost('feedface')).rejects.toThrow();
expect(useOrbitStore.getState().phase).toBe('idle');
});
it('throws when we are not the session host', async () => {
remote.readOrbitState.mockResolvedValue(buildState({ host: 'someone-else' }));
await expect(resumeOrbitSessionAsHost('feedface')).rejects.toThrow();
expect(useOrbitStore.getState().role).toBeNull();
});
it('throws when there is no active user', async () => {
authState.username = undefined;
await expect(resumeOrbitSessionAsHost('feedface')).rejects.toThrow();
});
});
+114
View File
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { makeInitialOrbitState } from '../../api/orbit';
const { orbitState, authState } = vi.hoisted(() => ({
orbitState: {
role: null as 'host' | 'guest' | null,
sessionId: null as string | null,
sessionPlaylistId: null as string | null,
outboxPlaylistId: null as string | null,
state: null as ReturnType<typeof makeInitialOrbitState> | null,
},
authState: { activeServerId: null as string | null },
}));
vi.mock('../../store/orbitStore', () => ({
useOrbitStore: { getState: () => orbitState },
}));
vi.mock('../../store/authStore', () => ({
useAuthStore: { getState: () => authState },
}));
import {
clearOrbitLastSession,
persistCurrentOrbitSession,
readOrbitLastSession,
readOrbitLastSessionSid,
saveOrbitLastSession,
type OrbitLastSession,
} from './lastSession';
const sample: OrbitLastSession = {
sid: 'deadbeef',
sessionPlaylistId: 'pl-1',
outboxPlaylistId: 'ob-1',
role: 'guest',
sessionName: 'Velvet Orbit',
hostUsername: 'alice',
serverId: 'srv-1',
savedAt: 1700000000000,
};
beforeEach(() => {
localStorage.clear();
orbitState.role = null;
orbitState.sessionId = null;
orbitState.sessionPlaylistId = null;
orbitState.outboxPlaylistId = null;
orbitState.state = null;
authState.activeServerId = null;
});
describe('orbit lastSession breadcrumb', () => {
it('round-trips a saved record', () => {
saveOrbitLastSession(sample);
expect(readOrbitLastSession()).toEqual(sample);
expect(readOrbitLastSessionSid()).toBe('deadbeef');
});
it('clears the record', () => {
saveOrbitLastSession(sample);
clearOrbitLastSession();
expect(readOrbitLastSession()).toBeNull();
expect(readOrbitLastSessionSid()).toBeNull();
});
it('returns null for a malformed / partial blob', () => {
localStorage.setItem('psysonic_orbit_last_session', JSON.stringify({ sid: 'x' }));
expect(readOrbitLastSession()).toBeNull();
});
it('returns null for a non-JSON value', () => {
localStorage.setItem('psysonic_orbit_last_session', 'not json{');
expect(readOrbitLastSession()).toBeNull();
});
it('persistCurrentOrbitSession snapshots a bound session', () => {
orbitState.role = 'host';
orbitState.sessionId = 'feedface';
orbitState.sessionPlaylistId = 'pl-9';
orbitState.outboxPlaylistId = 'ob-9';
orbitState.state = makeInitialOrbitState({ sid: 'feedface', host: 'bob', name: 'Night Run' });
authState.activeServerId = 'srv-7';
persistCurrentOrbitSession();
const rec = readOrbitLastSession();
expect(rec).toMatchObject({
sid: 'feedface',
sessionPlaylistId: 'pl-9',
outboxPlaylistId: 'ob-9',
role: 'host',
sessionName: 'Night Run',
hostUsername: 'bob',
serverId: 'srv-7',
});
expect(typeof rec?.savedAt).toBe('number');
});
it('persistCurrentOrbitSession is a no-op when no session is bound', () => {
authState.activeServerId = 'srv-7';
persistCurrentOrbitSession();
expect(readOrbitLastSession()).toBeNull();
});
it('persistCurrentOrbitSession is a no-op without an active server', () => {
orbitState.role = 'host';
orbitState.sessionId = 'feedface';
orbitState.sessionPlaylistId = 'pl-9';
orbitState.outboxPlaylistId = 'ob-9';
orbitState.state = makeInitialOrbitState({ sid: 'feedface', host: 'bob', name: 'Night Run' });
persistCurrentOrbitSession();
expect(readOrbitLastSession()).toBeNull();
});
});
+107
View File
@@ -0,0 +1,107 @@
import { useOrbitStore, type OrbitRole } from '../../store/orbitStore';
import { useAuthStore } from '../../store/authStore';
/**
* Orbit — last-session breadcrumb (survives an app restart).
*
* The Orbit store itself is intentionally in-memory only (see orbitStore.ts):
* a session is transient and we never resurrect a stale local mirror. This
* module persists the one small thing we *do* need across a restart — enough
* to recognise "this client was in session X (as host/guest) on server Y" and
* offer a one-click rejoin on next launch.
*
* Written on host-start / guest-join, wiped on any clean exit (host end, guest
* leave, kick/remove/host-timeout, or the user declining the reconnect prompt).
* A process crash / force-quit leaves it in place — that's exactly the case the
* reconnect prompt exists for.
*/
const STORAGE_KEY = 'psysonic_orbit_last_session';
export interface OrbitLastSession {
/** Session id (8 hex). */
sid: string;
/** Navidrome playlist id of the canonical session playlist. */
sessionPlaylistId: string;
/** Navidrome playlist id of our own outbox. */
outboxPlaylistId: string;
/** Our role in the session. */
role: OrbitRole;
/** Human-readable session name (for the reconnect prompt copy). */
sessionName: string;
/** Host's Navidrome username (for the reconnect prompt copy). */
hostUsername: string;
/** Active server id at save time — reconnect is only offered on the same server. */
serverId: string;
/** Wall-clock ms the breadcrumb was written. */
savedAt: number;
}
export function saveOrbitLastSession(rec: OrbitLastSession): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(rec));
} catch {
/* quota / disabled storage — non-fatal, we just won't offer reconnect */
}
}
export function readOrbitLastSession(): OrbitLastSession | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const o = JSON.parse(raw) as Partial<OrbitLastSession>;
if (
typeof o?.sid === 'string' &&
typeof o.sessionPlaylistId === 'string' &&
typeof o.outboxPlaylistId === 'string' &&
(o.role === 'host' || o.role === 'guest') &&
typeof o.sessionName === 'string' &&
typeof o.hostUsername === 'string' &&
typeof o.serverId === 'string' &&
typeof o.savedAt === 'number'
) {
return o as OrbitLastSession;
}
return null;
} catch {
return null;
}
}
/**
* Cheap sid-only read, used by the app-start orphan sweep to protect a
* pending-reconnect session from deletion regardless of effect ordering.
*/
export function readOrbitLastSessionSid(): string | null {
return readOrbitLastSession()?.sid ?? null;
}
export function clearOrbitLastSession(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* non-fatal */
}
}
/**
* Snapshot the currently-bound session into the breadcrumb. Called right after
* a host-start / guest-join binds the store. No-op unless a session is fully
* bound and an active server id is known.
*/
export function persistCurrentOrbitSession(): void {
const s = useOrbitStore.getState();
if (!s.role || !s.sessionId || !s.sessionPlaylistId || !s.outboxPlaylistId || !s.state) return;
const serverId = useAuthStore.getState().activeServerId;
if (!serverId) return;
saveOrbitLastSession({
sid: s.sessionId,
sessionPlaylistId: s.sessionPlaylistId,
outboxPlaylistId: s.outboxPlaylistId,
role: s.role,
sessionName: s.state.name,
hostUsername: s.state.host,
serverId,
savedAt: Date.now(),
});
}