exp(orbit): host lifecycle and tick hook

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-23 00:47:53 +02:00
parent e398d68184
commit a45943d078
2 changed files with 332 additions and 0 deletions
+100
View File
@@ -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<OrbitState> => {
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]);
}
+232
View File
@@ -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<OrbitState | null> {
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<void> {
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<void> {
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<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 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<void> {
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>): OrbitState | null {
const current = useOrbitStore.getState().state;
if (!current) return null;
const next: OrbitState = { ...current, ...patch };
useOrbitStore.getState().setState(next);
return next;
}