mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
exp(orbit): guest join/leave + tick hook
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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]);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
updatePlaylistMeta,
|
updatePlaylistMeta,
|
||||||
deletePlaylist,
|
deletePlaylist,
|
||||||
getPlaylist,
|
getPlaylist,
|
||||||
|
getPlaylists,
|
||||||
} from '../api/subsonic';
|
} from '../api/subsonic';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
@@ -230,3 +231,172 @@ export function patchOrbitState(patch: Partial<OrbitState>): OrbitState | null {
|
|||||||
useOrbitStore.getState().setState(next);
|
useOrbitStore.getState().setState(next);
|
||||||
return 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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user