From a45943d07882df3d616a0412dca9d3ad47c18fa6 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Thu, 23 Apr 2026 00:47:53 +0200 Subject: [PATCH] exp(orbit): host lifecycle and tick hook Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/useOrbitHost.ts | 100 ++++++++++++++++ src/utils/orbit.ts | 232 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 src/hooks/useOrbitHost.ts create mode 100644 src/utils/orbit.ts diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts new file mode 100644 index 00000000..f782049f --- /dev/null +++ b/src/hooks/useOrbitHost.ts @@ -0,0 +1,100 @@ +import { useEffect, useRef } from 'react'; +import { useOrbitStore } from '../store/orbitStore'; +import { usePlayerStore } from '../store/playerStore'; +import { + writeOrbitState, + writeOrbitHeartbeat, + patchOrbitState, +} from '../utils/orbit'; +import { orbitOutboxPlaylistName, type OrbitState } from '../api/orbit'; + +/** + * Orbit — host-side tick hook. + * + * Mounted once at the app shell level; only does work when the local store + * says we're the host of an active session. Two independent timers: + * + * - **State tick** (2.5 s): snapshot isPlaying + position + current track + * from the player store, patch the local OrbitState, push to the + * session playlist's comment. + * - **Heartbeat tick** (10 s): refresh the host's own outbox playlist's + * comment with a fresh timestamp so the later-added participant + * pipeline can treat the host symmetrically. + * + * Writes are best-effort — a transient Navidrome outage just means guests + * see stale state for a tick or two and catch up on the next write. + * Phase 2 does not yet consume anything from guests. + */ + +const STATE_TICK_MS = 2_500; +const HEARTBEAT_TICK_MS = 10_000; + +export function useOrbitHost(): 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); + + // Refs hold the last values we used to build the patch — cheap to + // recompute against, no need to subscribe to every playerStore tick. + const lastPushedAtRef = useRef(0); + + const active = role === 'host' && phase === 'active' && !!sessionPlaylistId; + + useEffect(() => { + if (!active || !sessionPlaylistId) return; + + const snapshotStatePatch = (): Partial => { + const p = usePlayerStore.getState(); + const now = Date.now(); + return { + isPlaying: p.isPlaying, + positionMs: Math.round((p.currentTime ?? 0) * 1000), + positionAt: now, + currentTrack: p.currentTrack + ? { + trackId: p.currentTrack.id, + // Phase 2: host's own locally-initiated plays are marked as + // authored by the host. Phase 4 replaces this with addedBy + // pulled from the guest outbox when consuming a suggestion. + addedBy: useOrbitStore.getState().state?.host ?? '', + addedAt: now, + } + : null, + }; + }; + + const pushState = async () => { + const next = patchOrbitState(snapshotStatePatch()); + if (!next) return; + try { + await writeOrbitState(sessionPlaylistId, next); + lastPushedAtRef.current = Date.now(); + } catch { /* best-effort; next tick retries */ } + }; + + // Immediate push on mount so guests see fresh state without waiting + // a full tick after the host comes online. + void pushState(); + + const id = window.setInterval(() => { void pushState(); }, STATE_TICK_MS); + return () => window.clearInterval(id); + }, [active, sessionPlaylistId]); + + useEffect(() => { + if (!active || !outboxPlaylistId || !sessionId) return; + const server = useOrbitStore.getState().state?.host; + if (!server) return; + const outboxName = orbitOutboxPlaylistName(sessionId, server); + + const pushHeartbeat = async () => { + try { await writeOrbitHeartbeat(outboxPlaylistId, outboxName); } + catch { /* best-effort */ } + }; + void pushHeartbeat(); + + const id = window.setInterval(() => { void pushHeartbeat(); }, HEARTBEAT_TICK_MS); + return () => window.clearInterval(id); + }, [active, outboxPlaylistId, sessionId]); +} diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts new file mode 100644 index 00000000..465ac402 --- /dev/null +++ b/src/utils/orbit.ts @@ -0,0 +1,232 @@ +import { + createPlaylist, + updatePlaylistMeta, + deletePlaylist, + getPlaylist, +} from '../api/subsonic'; +import { useAuthStore } from '../store/authStore'; +import { useOrbitStore } from '../store/orbitStore'; +import { + makeInitialOrbitState, + orbitOutboxPlaylistName, + orbitSessionPlaylistName, + parseOrbitState, + ORBIT_DEFAULT_MAX_USERS, + ORBIT_STATE_MAX_BYTES, + type OrbitOutboxMeta, + type OrbitState, +} from '../api/orbit'; + +/** + * Orbit — host-side lifecycle primitives. + * + * Phase 2 scope: creating / ending a session, serialising state into the + * canonical playlist comment, writing a heartbeat into the host's own + * outbox. No guest-side logic here. + * + * All functions talk to Navidrome through the existing Subsonic wrappers; + * no new transport work. + */ + +// ── ID generation ─────────────────────────────────────────────────────── + +/** 8 lowercase hex chars — unique enough for concurrent-session collision-free naming. */ +function generateSessionId(): string { + const bytes = new Uint8Array(4); + crypto.getRandomValues(bytes); + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); +} + +// ── Serialisation ─────────────────────────────────────────────────────── + +/** + * Serialise the state blob for writing into a playlist comment. Emits a + * plain JSON string. Throws when the output exceeds `ORBIT_STATE_MAX_BYTES` + * — callers should trim optional fields (oldest queue entries / kicked + * usernames) and retry, rather than write something truncated. + */ +export function serialiseOrbitState(state: OrbitState): string { + const json = JSON.stringify(state); + // Encode-length check — emoji-heavy session names could inflate UTF-8 bytes + // beyond the string's .length count. + const byteLen = new TextEncoder().encode(json).length; + if (byteLen > ORBIT_STATE_MAX_BYTES) { + throw new OrbitStateTooLarge(byteLen); + } + return json; +} + +export class OrbitStateTooLarge extends Error { + constructor(public readonly bytes: number) { + super(`Orbit state blob (${bytes} bytes) exceeds ${ORBIT_STATE_MAX_BYTES} byte budget`); + this.name = 'OrbitStateTooLarge'; + } +} + +function serialiseOutboxMeta(meta: OrbitOutboxMeta): string { + return JSON.stringify(meta); +} + +// ── Remote reads ──────────────────────────────────────────────────────── + +/** Pull + parse the canonical state from the session playlist. Null on miss or parse error. */ +export async function readOrbitState(sessionPlaylistId: string): Promise { + try { + const { playlist } = await getPlaylist(sessionPlaylistId); + if (!playlist.comment) return null; + let raw: unknown; + try { raw = JSON.parse(playlist.comment); } catch { return null; } + return parseOrbitState(raw); + } catch { return null; } +} + +// ── Remote writes ─────────────────────────────────────────────────────── + +/** + * Write the state blob into the session playlist's comment. + * + * NOTE (design doc "known rough edges"): `updatePlaylist.view` with name + + * comment MUST preserve the track list. Confirmed to work on Navidrome via + * observation in PR #256 (playlist-editor); if a future Navidrome release + * ever changes that, we need to switch to `updatePlaylist` with the full + * track list echoed back. + */ +export async function writeOrbitState( + sessionPlaylistId: string, + state: OrbitState, +): Promise { + const comment = serialiseOrbitState(state); + const name = orbitSessionPlaylistName(state.sid); + await updatePlaylistMeta(sessionPlaylistId, name, comment, /* public */ true); +} + +/** + * Write a heartbeat into the given outbox playlist's comment. Host keeps one + * for symmetry + to feed its own presence into the participants pipeline + * (used from Phase 4 onwards when guests look for host liveness). + */ +export async function writeOrbitHeartbeat( + outboxPlaylistId: string, + outboxName: string, +): Promise { + const meta: OrbitOutboxMeta = { ts: Date.now() }; + await updatePlaylistMeta(outboxPlaylistId, outboxName, serialiseOutboxMeta(meta), /* public */ true); +} + +// ── Host lifecycle ────────────────────────────────────────────────────── + +export interface StartOrbitArgs { + /** Human-readable name the host chose. */ + name: string; + /** Max participants (defaults to `ORBIT_DEFAULT_MAX_USERS`). */ + maxUsers?: number; +} + +/** + * Host: create a new session. + * + * Creates both the canonical session playlist and the host's own outbox, + * seeds the state blob + heartbeat, binds the store, sets phase to `active`. + * + * Throws if the Navidrome server isn't available or lacks a logged-in user. + * On throw the store is left in the pre-call state — nothing partially bound. + */ +export async function startOrbitSession(args: StartOrbitArgs): Promise { + 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 start while phase is ${store.phase}`); + } + + store.setPhase('starting'); + + let sessionPlaylistId: string | null = null; + let outboxPlaylistId: string | null = null; + try { + const sid = generateSessionId(); + const sessionName = orbitSessionPlaylistName(sid); + const outboxName = orbitOutboxPlaylistName(sid, username); + + // Create both playlists. Navidrome's createPlaylist returns the created + // object with its new id. + const sessionPlaylist = await createPlaylist(sessionName); + sessionPlaylistId = sessionPlaylist.id; + + const outboxPlaylist = await createPlaylist(outboxName); + outboxPlaylistId = outboxPlaylist.id; + + // Seed state blob + heartbeat. We use updatePlaylistMeta instead of + // separate create-with-comment because Subsonic's createPlaylist doesn't + // take a comment argument. + const state = makeInitialOrbitState({ + sid, + host: username, + name: args.name, + maxUsers: args.maxUsers ?? ORBIT_DEFAULT_MAX_USERS, + }); + await writeOrbitState(sessionPlaylistId, state); + await writeOrbitHeartbeat(outboxPlaylistId, outboxName); + + // Bind local store — session is now live. + useOrbitStore.setState({ + role: 'host', + sessionId: sid, + sessionPlaylistId, + outboxPlaylistId, + phase: 'active', + state, + errorMessage: null, + }); + + return state; + } catch (err) { + // Best-effort cleanup of anything we managed to create before the failure. + if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* ignore */ } } + if (sessionPlaylistId) { try { await deletePlaylist(sessionPlaylistId); } catch { /* ignore */ } } + useOrbitStore.getState().setPhase('idle'); + throw err; + } +} + +/** + * Host: end the session cleanly. + * + * Writes `ended: true` first so any poll-in-progress from a guest sees the + * signal, then deletes both playlists and resets the local store. Each step + * is best-effort; if something's already gone server-side we still zero out + * local state so the UI returns to idle. + */ +export async function endOrbitSession(): Promise { + const { role, state, sessionPlaylistId, outboxPlaylistId } = useOrbitStore.getState(); + if (role !== 'host') return; + + // 1) Flip `ended` so guests notice on their next poll even if deletion fails. + if (sessionPlaylistId && state) { + try { + await writeOrbitState(sessionPlaylistId, { ...state, ended: true }); + } catch { /* best-effort */ } + } + + // 2) Delete both playlists. Order: outbox first — if session delete fails, + // a stale session playlist with ended=true is fine; a stale outbox without + // a session is noise. + if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ } } + if (sessionPlaylistId) { try { await deletePlaylist(sessionPlaylistId); } catch { /* best-effort */ } } + + // 3) Local teardown. + useOrbitStore.getState().reset(); +} + +// ── Store helpers used by the tick hook ──────────────────────────────── + +/** Merge a patch into the store's state blob, keeping nullability. */ +export function patchOrbitState(patch: Partial): OrbitState | null { + const current = useOrbitStore.getState().state; + if (!current) return null; + const next: OrbitState = { ...current, ...patch }; + useOrbitStore.getState().setState(next); + return next; +}