mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
exp(orbit): host lifecycle and tick hook
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user