exp(orbit): scaffold store and types

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-23 00:41:41 +02:00
parent 694567843f
commit e398d68184
2 changed files with 232 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
/**
* Orbit — shared-session state types.
*
* The canonical state blob lives in the comment field of a dedicated
* server-side playlist (`__psyorbit_[sid]__`). Host writes, guests read.
* Per-user "outbox" playlists (`__psyorbit_[sid]_from_[username]__`)
* carry suggestions + heartbeats the other way.
*
* This file is types + a few pure helpers only — no network, no store.
*/
/** Bump whenever the on-wire schema changes incompatibly. */
export const ORBIT_STATE_VERSION = 3 as const;
/** Prefix for the canonical session playlist name. Append an 8-hex session id. */
export const ORBIT_PLAYLIST_PREFIX = '__psyorbit_';
/** Full canonical session playlist name for a given session id. */
export function orbitSessionPlaylistName(sessionId: string): string {
return `${ORBIT_PLAYLIST_PREFIX}${sessionId}__`;
}
/** Full per-user outbox playlist name. Host reads these, guests own one. */
export function orbitOutboxPlaylistName(sessionId: string, username: string): string {
return `${ORBIT_PLAYLIST_PREFIX}${sessionId}_from_${username}__`;
}
/** One queued/current track + who added it, for attribution. */
export interface OrbitQueueItem {
trackId: string;
/** Navidrome username of the participant who suggested this track. */
addedBy: string;
/** Wall-clock ms when the host consumed this track from its originator's outbox. */
addedAt: number;
}
/** One participant's presence record. */
export interface OrbitParticipant {
user: string;
/** Wall-clock ms when the host first registered this participant. */
joinedAt: number;
/** Wall-clock ms of the participant's most recent outbox heartbeat. */
lastHeartbeat: number;
}
/**
* The canonical session state — exactly what's serialised into the
* session playlist's comment field. Keep lean; the comment has a ~4 KB
* self-imposed budget.
*/
export interface OrbitState {
v: typeof ORBIT_STATE_VERSION;
/** Session id (8 hex chars). */
sid: string;
/** Navidrome username of the host. */
host: string;
/** Human-readable session name set by the host at start. */
name: string;
/** Epoch ms when the session was created. */
started: number;
/** Host-configurable cap on concurrent participants. */
maxUsers: number;
/** Currently-playing track (host's playback), or null when stopped. */
currentTrack: OrbitQueueItem | null;
/** Host's live play/pause state. */
isPlaying: boolean;
/** Host's last reported playback position in ms. */
positionMs: number;
/** Wall-clock ms of the `positionMs` snapshot, for drift calculation. */
positionAt: number;
/** Upcoming queue (not including `currentTrack`). */
queue: OrbitQueueItem[];
/** Epoch ms of the last queue shuffle. */
lastShuffle: number;
/** Currently-present participants (excluding the host). */
participants: OrbitParticipant[];
/** Usernames blocked from re-joining this session. */
kicked: string[];
/** Set when the host has ended the session; guests should exit on next poll. */
ended?: boolean;
}
/** What the guest's outbox-playlist comment holds (heartbeat only, for now). */
export interface OrbitOutboxMeta {
/** Wall-clock ms of this heartbeat. */
ts: number;
}
/** Our self-imposed limit on serialised `OrbitState`. Drop oldest non-essential fields if exceeded. */
export const ORBIT_STATE_MAX_BYTES = 4096;
/** Default value of `OrbitState.maxUsers` when the host hasn't picked one. */
export const ORBIT_DEFAULT_MAX_USERS = 10;
/**
* Build a fresh state blob for a brand-new session. Used by the host on start.
*/
export function makeInitialOrbitState(args: {
sid: string;
host: string;
name: string;
maxUsers?: number;
}): OrbitState {
const now = Date.now();
return {
v: ORBIT_STATE_VERSION,
sid: args.sid,
host: args.host,
name: args.name,
started: now,
maxUsers: args.maxUsers ?? ORBIT_DEFAULT_MAX_USERS,
currentTrack: null,
isPlaying: false,
positionMs: 0,
positionAt: now,
queue: [],
lastShuffle: now,
participants: [],
kicked: [],
};
}
/**
* Validate + parse an incoming state blob (untrusted JSON from the playlist
* comment). Returns null on structural mismatch or schema-version drift.
*/
export function parseOrbitState(raw: unknown): OrbitState | null {
if (!raw || typeof raw !== 'object') return null;
const s = raw as Partial<OrbitState>;
if (s.v !== ORBIT_STATE_VERSION) return null;
if (typeof s.sid !== 'string' || typeof s.host !== 'string') return null;
if (typeof s.name !== 'string' || typeof s.started !== 'number') return null;
if (typeof s.maxUsers !== 'number' || typeof s.isPlaying !== 'boolean') return null;
if (typeof s.positionMs !== 'number' || typeof s.positionAt !== 'number') return null;
if (!Array.isArray(s.queue) || !Array.isArray(s.participants) || !Array.isArray(s.kicked)) return null;
if (typeof s.lastShuffle !== 'number') return null;
// currentTrack can be null or an object — no deeper validation here; the
// producer is our own code and an item with missing fields would only hurt
// the attribution UI, not correctness.
return s as OrbitState;
}
/** Quickly derive the host's estimated live playback position on a guest. */
export function estimateLivePosition(state: OrbitState, nowMs: number): number {
if (!state.isPlaying) return state.positionMs;
return state.positionMs + (nowMs - state.positionAt);
}
+85
View File
@@ -0,0 +1,85 @@
import { create } from 'zustand';
import type { OrbitState } from '../api/orbit';
/**
* Orbit — local session store.
*
* Mirrors the remote canonical state for the UI, plus a handful of
* client-only fields (our role in the session, the playlist ids we're
* bound to, lifecycle phase). Not persisted — a session is transient by
* design; if the app restarts mid-session, we re-join on next open
* rather than resurrect stale local state.
*
* Phase 1 is intentionally thin: only `set`/`reset` plumbing so later
* phases (host/guest lifecycle, track pipeline) can drop into place
* without touching the store shape.
*/
export type OrbitRole = 'host' | 'guest';
/** Fine-grained lifecycle phase. Drives which modal/indicator UI is visible. */
export type OrbitPhase =
/** No session bound. */
| 'idle'
/** Host: creating the session playlist and seeding state. */
| 'starting'
/** Guest: auth + lookup before commit. */
| 'joining'
/** Session established; polling cycle active. */
| 'active'
/** Host ended the session; showing exit modal. */
| 'ended'
/** Unrecoverable error (server unreachable, playlist vanished, etc.). */
| 'error';
interface OrbitStore {
/** Current role in the session, or null when idle. */
role: OrbitRole | null;
/** Active session id, or null. */
sessionId: string | null;
/** Navidrome playlist id of the canonical session playlist. */
sessionPlaylistId: string | null;
/** Navidrome playlist id of our own outbox (exists for both host and guest). */
outboxPlaylistId: string | null;
/** Lifecycle phase. */
phase: OrbitPhase;
/** Latest-known canonical state (last poll). Null while starting/joining. */
state: OrbitState | null;
/** Human-readable error when `phase === 'error'`. */
errorMessage: string | null;
// ── Setters (Phase 1 scaffolding; later phases add real actions) ────────
setPhase: (phase: OrbitPhase) => void;
setRole: (role: OrbitRole | null) => void;
setSessionBinding: (args: {
sessionId: string | null;
sessionPlaylistId: string | null;
outboxPlaylistId: string | null;
}) => void;
setState: (state: OrbitState | null) => void;
setError: (message: string | null) => void;
/** Tear down the session locally. Does NOT clean up remote playlists. */
reset: () => void;
}
const initialState = {
role: null,
sessionId: null,
sessionPlaylistId: null,
outboxPlaylistId: null,
phase: 'idle' as OrbitPhase,
state: null,
errorMessage: null,
} satisfies Omit<OrbitStore, 'setPhase' | 'setRole' | 'setSessionBinding' | 'setState' | 'setError' | 'reset'>;
export const useOrbitStore = create<OrbitStore>()((set) => ({
...initialState,
setPhase: (phase) => set({ phase }),
setRole: (role) => set({ role }),
setSessionBinding: ({ sessionId, sessionPlaylistId, outboxPlaylistId }) =>
set({ sessionId, sessionPlaylistId, outboxPlaylistId }),
setState: (state) => set({ state }),
setError: (message) => set({ phase: message ? 'error' : 'idle', errorMessage: message }),
reset: () => set({ ...initialState }),
}));