mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 901d260bf1 | |||
| ebef8aa061 | |||
| 9de56bf638 | |||
| 842e041a6e | |||
| 0de488335e | |||
| 2c95a3499c | |||
| de191e45d5 |
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Создать сессию',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user