mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
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.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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 ranRef = useRef(false);
|
||||
const firedRef = useRef(false);
|
||||
|
||||
// One-shot startup preflight: is there a live session worth offering?
|
||||
useEffect(() => {
|
||||
if (ranRef.current) return;
|
||||
if (!isLoggedIn || !activeServerId) return; // wait for auth hydration
|
||||
if (orbitRole !== null) return; // already bound to a session
|
||||
ranRef.current = true;
|
||||
|
||||
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 (!sessionPlaylistId) { clearOrbitLastSession(); return; }
|
||||
const state = await readOrbitState(sessionPlaylistId);
|
||||
if (!state || state.ended) { 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) {
|
||||
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) {
|
||||
clearOrbitLastSession();
|
||||
return;
|
||||
}
|
||||
if (!cancelled) setCandidate(rec);
|
||||
} catch {
|
||||
/* network hiccup — keep the breadcrumb, just skip the prompt this launch */
|
||||
}
|
||||
})();
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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 ── */
|
||||
|
||||
Reference in New Issue
Block a user