exp(orbit): guest join/leave + tick hook

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-23 00:54:58 +02:00
parent a45943d078
commit 87c51e3b11
2 changed files with 265 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
import { useEffect } from 'react';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import {
readOrbitState,
writeOrbitHeartbeat,
leaveOrbitSession,
} from '../utils/orbit';
import { orbitOutboxPlaylistName } from '../api/orbit';
/**
* Orbit — guest-side tick hook.
*
* Mounted at the app shell; only does work when the local store says we're
* a guest in an active session. Two independent timers:
*
* - **State read** (2.5 s): pull the canonical state from the session
* playlist's comment and mirror it into the store. Detect session end
* or own kick and tear down.
* - **Heartbeat** (10 s): refresh the guest's own outbox playlist
* comment so the host's participant sweep sees the user as alive.
*
* Reads are best-effort; a transient Navidrome outage just delays state
* updates by a tick or two. The session continues locally as long as the
* playback engine has the current track loaded.
*/
const STATE_READ_TICK_MS = 2_500;
const HEARTBEAT_TICK_MS = 10_000;
export function useOrbitGuest(): void {
const role = useOrbitStore(s => s.role);
const phase = useOrbitStore(s => s.phase);
const sessionPlaylistId = useOrbitStore(s => s.sessionPlaylistId);
const outboxPlaylistId = useOrbitStore(s => s.outboxPlaylistId);
const sessionId = useOrbitStore(s => s.sessionId);
const active = role === 'guest' && phase === 'active' && !!sessionPlaylistId;
// ── State read + end/kick detection ──────────────────────────────────
useEffect(() => {
if (!active || !sessionPlaylistId) return;
let cancelled = false;
const pull = async () => {
const state = await readOrbitState(sessionPlaylistId);
if (cancelled) return;
if (!state) {
// Session playlist is gone — host must have nuked it. Tear down
// silently; the exit-modal is the "ended" path below, not this.
void leaveOrbitSession();
return;
}
useOrbitStore.getState().setState(state);
// Host signalled session end: surface via `phase`, let the UI handle
// the modal. Outbox cleanup still happens via leaveOrbitSession().
if (state.ended) {
useOrbitStore.getState().setPhase('ended');
return;
}
// Kicked: transition into `ended` phase but with a different
// errorMessage so the UI can show the right copy.
const me = useAuthStore.getState().getActiveServer()?.username;
if (me && state.kicked.includes(me)) {
useOrbitStore.getState().setError('kicked');
}
};
void pull();
const id = window.setInterval(() => { void pull(); }, STATE_READ_TICK_MS);
return () => { cancelled = true; window.clearInterval(id); };
}, [active, sessionPlaylistId]);
// ── Heartbeat ────────────────────────────────────────────────────────
useEffect(() => {
if (!active || !outboxPlaylistId || !sessionId) return;
const me = useAuthStore.getState().getActiveServer()?.username;
if (!me) return;
const outboxName = orbitOutboxPlaylistName(sessionId, me);
const beat = async () => {
try { await writeOrbitHeartbeat(outboxPlaylistId, outboxName); }
catch { /* best-effort */ }
};
void beat();
const id = window.setInterval(() => { void beat(); }, HEARTBEAT_TICK_MS);
return () => window.clearInterval(id);
}, [active, outboxPlaylistId, sessionId]);
}
+170
View File
@@ -3,6 +3,7 @@ import {
updatePlaylistMeta,
deletePlaylist,
getPlaylist,
getPlaylists,
} from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import { useOrbitStore } from '../store/orbitStore';
@@ -230,3 +231,172 @@ export function patchOrbitState(patch: Partial<OrbitState>): OrbitState | null {
useOrbitStore.getState().setState(next);
return next;
}
// ── Share link ──────────────────────────────────────────────────────────
export const ORBIT_SHARE_SCHEME = 'psysonic2://orbit/';
export interface OrbitShareLink {
/** Base URL of the Navidrome server (decoded). */
serverBase: string;
/** Session id (8 hex chars). */
sid: string;
}
/**
* Parse a `psysonic2://orbit/<server-b64>/<sid>` link. Returns null on any
* shape mismatch — the caller decides what to do (show error toast etc.).
* Accepts both the `psysonic2://` prefix and a bare string if the OS-level
* handler has already stripped the scheme.
*/
export function parseOrbitShareLink(url: string): OrbitShareLink | null {
if (!url) return null;
const stripped = url.startsWith(ORBIT_SHARE_SCHEME)
? url.slice(ORBIT_SHARE_SCHEME.length)
: url.startsWith('orbit/') ? url.slice('orbit/'.length) : null;
if (stripped == null) return null;
const slash = stripped.indexOf('/');
if (slash <= 0) return null;
const serverB64 = stripped.slice(0, slash);
const sid = stripped.slice(slash + 1).replace(/\/+$/, '');
if (!/^[0-9a-f]{8}$/i.test(sid)) return null;
let serverBase: string;
try {
serverBase = atob(serverB64);
} catch { return null; }
try { new URL(serverBase); } catch { return null; }
return { serverBase, sid };
}
/** Build a share link for a live session. */
export function buildOrbitShareLink(serverBase: string, sid: string): string {
return `${ORBIT_SHARE_SCHEME}${btoa(serverBase)}/${sid}`;
}
// ── Playlist lookup ─────────────────────────────────────────────────────
/**
* Find the Navidrome playlist id of a session given its session id.
* Scans the user's visible playlist list — Navidrome exposes public
* playlists from other users, so a guest can find the host's session.
*/
export async function findSessionPlaylistId(sid: string): Promise<string | null> {
const target = orbitSessionPlaylistName(sid);
try {
const all = await getPlaylists();
const hit = all.find(p => p.name === target);
return hit?.id ?? null;
} catch { return null; }
}
// ── Guest lifecycle ─────────────────────────────────────────────────────
export class OrbitJoinError extends Error {
constructor(
public readonly reason: 'not-found' | 'ended' | 'full' | 'kicked' | 'no-user' | 'server-error',
message: string,
) {
super(message);
this.name = 'OrbitJoinError';
}
}
/**
* Guest: join an existing session by id.
*
* Assumes the user is already authenticated against the correct Navidrome
* server — the caller's UI layer handles the magic-sharing flow when the
* encoded server in the share link doesn't match the active one.
*
* Side effects on success:
* - creates this user's outbox playlist and writes a first heartbeat
* - binds `useOrbitStore` to the session (role = guest, phase = active)
* - populates the store's `state` mirror with the last-known blob
*
* Throws `OrbitJoinError` on any gate failure; caller shows an error
* modal and does nothing else.
*/
export async function joinOrbitSession(sid: string): Promise<OrbitState> {
const server = useAuthStore.getState().getActiveServer();
const username = server?.username;
if (!username) throw new OrbitJoinError('no-user', 'No active Navidrome server / user');
const store = useOrbitStore.getState();
if (store.phase !== 'idle') {
throw new OrbitJoinError('server-error', `Cannot join while phase is ${store.phase}`);
}
store.setPhase('joining');
let outboxPlaylistId: string | null = null;
try {
// 1) Locate the session playlist and read its state blob.
const sessionPlaylistId = await findSessionPlaylistId(sid);
if (!sessionPlaylistId) throw new OrbitJoinError('not-found', `Session ${sid} not found on server`);
const state = await readOrbitState(sessionPlaylistId);
if (!state) throw new OrbitJoinError('not-found', `Session ${sid} has no valid state`);
if (state.ended) throw new OrbitJoinError('ended', `Session ${sid} has ended`);
// 2) Gate: not kicked, not full. Note: host isn't in `participants` itself,
// so `maxUsers` counts guests only.
if (state.kicked.includes(username)) {
throw new OrbitJoinError('kicked', `You were removed from session ${sid}`);
}
const alreadyInside = state.participants.some(p => p.user === username);
if (!alreadyInside && state.participants.length >= state.maxUsers) {
throw new OrbitJoinError('full', `Session ${sid} is full (${state.maxUsers}/${state.maxUsers})`);
}
// 3) Create our outbox + first heartbeat.
const outboxName = orbitOutboxPlaylistName(sid, username);
// Guard against a stale outbox from a previous abandoned join attempt —
// if one exists under the same name, reuse its id instead of creating
// a duplicate (Navidrome allows duplicate names but it'd leak).
const existing = (await getPlaylists().catch(() => [])).find(p => p.name === outboxName);
if (existing) {
outboxPlaylistId = existing.id;
} else {
const outbox = await createPlaylist(outboxName);
outboxPlaylistId = outbox.id;
}
await writeOrbitHeartbeat(outboxPlaylistId, outboxName);
// 4) Bind the local store. The host's next poll will register us in
// `participants` — we don't self-mutate the canonical state.
useOrbitStore.setState({
role: 'guest',
sessionId: sid,
sessionPlaylistId,
outboxPlaylistId,
phase: 'active',
state,
errorMessage: null,
});
return state;
} catch (err) {
// Best-effort cleanup.
if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* ignore */ } }
useOrbitStore.getState().setPhase('idle');
throw err;
}
}
/**
* Guest: leave a session voluntarily.
*
* Deletes our outbox (so the host stops counting us after its next sweep)
* and resets the local store. Best-effort on each step. Does NOT touch the
* canonical session playlist — that's the host's property.
*/
export async function leaveOrbitSession(): Promise<void> {
const { role, outboxPlaylistId } = useOrbitStore.getState();
if (role !== 'guest') return;
if (outboxPlaylistId) {
try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ }
}
useOrbitStore.getState().reset();
}