From e398d68184e3b86c794fb05ea0cbbdb1aa897a79 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Thu, 23 Apr 2026 00:41:41 +0200 Subject: [PATCH 01/53] exp(orbit): scaffold store and types Co-Authored-By: Claude Opus 4.7 (1M context) --- src/api/orbit.ts | 147 ++++++++++++++++++++++++++++++++++++++++ src/store/orbitStore.ts | 85 +++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 src/api/orbit.ts create mode 100644 src/store/orbitStore.ts diff --git a/src/api/orbit.ts b/src/api/orbit.ts new file mode 100644 index 00000000..beca5dc1 --- /dev/null +++ b/src/api/orbit.ts @@ -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; + 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); +} diff --git a/src/store/orbitStore.ts b/src/store/orbitStore.ts new file mode 100644 index 00000000..4ac1357c --- /dev/null +++ b/src/store/orbitStore.ts @@ -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; + +export const useOrbitStore = create()((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 }), +})); From a45943d07882df3d616a0412dca9d3ad47c18fa6 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Thu, 23 Apr 2026 00:47:53 +0200 Subject: [PATCH 02/53] 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; +} From 87c51e3b11a2d9864dc747fe7ab6b8fa3805a60b Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Thu, 23 Apr 2026 00:54:58 +0200 Subject: [PATCH 03/53] exp(orbit): guest join/leave + tick hook Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/useOrbitGuest.ts | 95 +++++++++++++++++++++ src/utils/orbit.ts | 170 +++++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 src/hooks/useOrbitGuest.ts diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts new file mode 100644 index 00000000..484fc7f0 --- /dev/null +++ b/src/hooks/useOrbitGuest.ts @@ -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]); +} diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index 465ac402..0a70088d 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -3,6 +3,7 @@ import { updatePlaylistMeta, deletePlaylist, getPlaylist, + getPlaylists, } from '../api/subsonic'; import { useAuthStore } from '../store/authStore'; import { useOrbitStore } from '../store/orbitStore'; @@ -230,3 +231,172 @@ export function patchOrbitState(patch: Partial): OrbitState | null { useOrbitStore.getState().setState(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//` 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 { + 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 { + 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 { + const { role, outboxPlaylistId } = useOrbitStore.getState(); + if (role !== 'guest') return; + + if (outboxPlaylistId) { + try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ } + } + + useOrbitStore.getState().reset(); +} From 60e0bbfa2a18d514273940be46413049297dc19f Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Thu, 23 Apr 2026 00:59:59 +0200 Subject: [PATCH 04/53] exp(orbit): track pipeline and participants sweep Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/useOrbitHost.ts | 32 ++++++-- src/utils/orbit.ts | 158 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 8 deletions(-) diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts index f782049f..f111cdcf 100644 --- a/src/hooks/useOrbitHost.ts +++ b/src/hooks/useOrbitHost.ts @@ -4,7 +4,8 @@ import { usePlayerStore } from '../store/playerStore'; import { writeOrbitState, writeOrbitHeartbeat, - patchOrbitState, + sweepGuestOutboxes, + applyOutboxSnapshotsToState, } from '../utils/orbit'; import { orbitOutboxPlaylistName, type OrbitState } from '../api/orbit'; @@ -45,7 +46,7 @@ export function useOrbitHost(): void { useEffect(() => { if (!active || !sessionPlaylistId) return; - const snapshotStatePatch = (): Partial => { + const snapshotPlayerPatch = (hostUsername: string): Partial => { const p = usePlayerStore.getState(); const now = Date.now(); return { @@ -55,10 +56,11 @@ export function useOrbitHost(): void { 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 ?? '', + // Locally-initiated plays are marked as authored by the host. + // Guest-suggested tracks that later become `currentTrack` will + // carry their original attribution because the queue-consume + // flow keeps the `addedBy` from the guest's outbox. + addedBy: hostUsername, addedAt: now, } : null, @@ -66,8 +68,22 @@ export function useOrbitHost(): void { }; const pushState = async () => { - const next = patchOrbitState(snapshotStatePatch()); - if (!next) return; + const store = useOrbitStore.getState(); + const base = store.state; + if (!base) return; + + // 1) Sweep every guest outbox: new suggestions + fresh heartbeats. + let afterSweep = base; + try { + const snaps = await sweepGuestOutboxes(base.sid, base.host); + afterSweep = applyOutboxSnapshotsToState(base, snaps); + } catch { /* best-effort; keep old participants and queue */ } + + // 2) Overlay the host's live playback snapshot. + const next: OrbitState = { ...afterSweep, ...snapshotPlayerPatch(base.host) }; + + // 3) Commit locally + push remote. + useOrbitStore.getState().setState(next); try { await writeOrbitState(sessionPlaylistId, next); lastPushedAtRef.current = Date.now(); diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index 0a70088d..60ed22c6 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -1,5 +1,6 @@ import { createPlaylist, + updatePlaylist, updatePlaylistMeta, deletePlaylist, getPlaylist, @@ -13,8 +14,11 @@ import { orbitSessionPlaylistName, parseOrbitState, ORBIT_DEFAULT_MAX_USERS, + ORBIT_PLAYLIST_PREFIX, ORBIT_STATE_MAX_BYTES, type OrbitOutboxMeta, + type OrbitParticipant, + type OrbitQueueItem, type OrbitState, } from '../api/orbit'; @@ -400,3 +404,157 @@ export async function leaveOrbitSession(): Promise { useOrbitStore.getState().reset(); } + +// ── Track pipeline ────────────────────────────────────────────────────── + +/** + * Guest: suggest a track to the session. + * + * Appends the track to our own outbox playlist. The host's next sweep will + * consume it and publish the authoritative queue update in the state blob. + * No state mutation here — the guest never touches canonical state. + */ +export async function suggestOrbitTrack(trackId: string): Promise { + const { role, outboxPlaylistId, sessionId } = useOrbitStore.getState(); + if (role !== 'guest') throw new Error('Not joined to a session as a guest'); + if (!outboxPlaylistId || !sessionId) throw new Error('No outbox bound'); + + // Read current outbox contents and append — createPlaylist.view with + // playlistId replaces songs wholesale, so we need to carry the existing + // list along. + const { songs } = await getPlaylist(outboxPlaylistId); + const nextIds = [...songs.map(s => s.id), trackId]; + await updatePlaylist(outboxPlaylistId, nextIds, songs.length); +} + +// ── Host-side outbox sweep ────────────────────────────────────────────── + +interface OutboxSnapshot { + user: string; + outboxPlaylistId: string; + /** Track IDs currently sitting in the outbox — these are the new suggestions. */ + trackIds: string[]; + /** Last heartbeat timestamp parsed from the outbox comment, or 0 if missing/broken. */ + lastHeartbeat: number; +} + +/** Extract `` from a filename matching `__psyorbit__from___`. */ +function parseOutboxPlaylistName(name: string, sid: string): string | null { + const prefix = `${ORBIT_PLAYLIST_PREFIX}${sid}_from_`; + if (!name.startsWith(prefix) || !name.endsWith('__')) return null; + const user = name.slice(prefix.length, name.length - 2); + return user.length > 0 ? user : null; +} + +/** + * Host: list all guest outbox playlists for the current session. + * Skips the host's own outbox — that's heartbeat-only, not a suggestion channel. + */ +async function listGuestOutboxes(sid: string, hostUsername: string): Promise> { + const all = await getPlaylists().catch(() => []); + const result: Array<{ id: string; name: string; user: string }> = []; + for (const p of all) { + const user = parseOutboxPlaylistName(p.name, sid); + if (!user || user === hostUsername) continue; + result.push({ id: p.id, name: p.name, user }); + } + return result; +} + +/** + * Host: read one outbox's contents (suggested tracks + heartbeat ts). + */ +async function readOutbox(playlistId: string): Promise<{ trackIds: string[]; lastHeartbeat: number }> { + try { + const { playlist, songs } = await getPlaylist(playlistId); + let ts = 0; + if (playlist.comment) { + try { + const meta = JSON.parse(playlist.comment) as Partial; + if (typeof meta.ts === 'number') ts = meta.ts; + } catch { /* malformed — treat as no heartbeat */ } + } + return { trackIds: songs.map(s => s.id), lastHeartbeat: ts }; + } catch { + return { trackIds: [], lastHeartbeat: 0 }; + } +} + +/** + * Host: sweep every guest outbox once. + * + * - Collects suggested track IDs from each outbox (returns them so the + * caller can wire them into the state queue with `addedBy` = user). + * - Captures the latest heartbeat ts per user for the participants list. + * - Clears the outbox track list after reading — a single-pass consume + * semantic: once the host has seen a track, the guest doesn't need to + * show it as "pending" any longer. The outbox's heartbeat comment is + * left untouched because the guest's own heartbeat hook keeps refreshing it. + * + * Returns a list of snapshots, one per live guest outbox. Errors on + * individual outboxes are swallowed — best-effort. + */ +export async function sweepGuestOutboxes(sid: string, hostUsername: string): Promise { + const outboxes = await listGuestOutboxes(sid, hostUsername); + const snaps: OutboxSnapshot[] = []; + for (const ob of outboxes) { + const { trackIds, lastHeartbeat } = await readOutbox(ob.id); + snaps.push({ user: ob.user, outboxPlaylistId: ob.id, trackIds, lastHeartbeat }); + if (trackIds.length > 0) { + // Clear the outbox tracks. Leaves the heartbeat comment untouched. + try { await updatePlaylist(ob.id, [], trackIds.length); } catch { /* best-effort */ } + } + } + return snaps; +} + +// ── State-blob construction from sweep results ───────────────────────── + +/** How long we consider a heartbeat still fresh. Longer than the guest tick so a single missed beat is tolerated. */ +export const ORBIT_HEARTBEAT_ALIVE_MS = 30_000; + +/** + * Fold sweep results into an updated `OrbitState`. + * + * - New queue items are appended to `state.queue`, with `addedBy` = user + * and `addedAt` = now. Host-authored tracks (host's own currentTrack + * progression) are handled elsewhere and don't flow through this path. + * - `participants` is rebuilt from scratch from the sweep heartbeats — + * anyone with a fresh heartbeat (< `ORBIT_HEARTBEAT_ALIVE_MS` old) and + * not in `kicked` counts as alive. Users that disappear from the sweep + * age out naturally. + */ +export function applyOutboxSnapshotsToState( + state: OrbitState, + snapshots: OutboxSnapshot[], + nowMs: number = Date.now(), +): OrbitState { + // ── Queue additions ── + const newItems: OrbitQueueItem[] = []; + for (const snap of snapshots) { + for (const trackId of snap.trackIds) { + newItems.push({ trackId, addedBy: snap.user, addedAt: nowMs }); + } + } + + // ── Participants rebuild ── + const prev = new Map(state.participants.map(p => [p.user, p])); + const participants: OrbitParticipant[] = []; + for (const snap of snapshots) { + if (state.kicked.includes(snap.user)) continue; + const fresh = snap.lastHeartbeat > 0 && (nowMs - snap.lastHeartbeat) < ORBIT_HEARTBEAT_ALIVE_MS; + if (!fresh) continue; + const existing = prev.get(snap.user); + participants.push({ + user: snap.user, + joinedAt: existing?.joinedAt ?? nowMs, + lastHeartbeat: snap.lastHeartbeat, + }); + } + + return { + ...state, + queue: newItems.length > 0 ? [...state.queue, ...newItems] : state.queue, + participants, + }; +} From cebf0e238d184593b18bbdba6e89269379dc068c Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Thu, 23 Apr 2026 01:03:18 +0200 Subject: [PATCH 05/53] exp(orbit): periodic shuffle Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/useOrbitHost.ts | 10 +++++++--- src/utils/orbit.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts index f111cdcf..82a69d15 100644 --- a/src/hooks/useOrbitHost.ts +++ b/src/hooks/useOrbitHost.ts @@ -6,6 +6,7 @@ import { writeOrbitHeartbeat, sweepGuestOutboxes, applyOutboxSnapshotsToState, + maybeShuffleQueue, } from '../utils/orbit'; import { orbitOutboxPlaylistName, type OrbitState } from '../api/orbit'; @@ -79,10 +80,13 @@ export function useOrbitHost(): void { afterSweep = applyOutboxSnapshotsToState(base, snaps); } catch { /* best-effort; keep old participants and queue */ } - // 2) Overlay the host's live playback snapshot. - const next: OrbitState = { ...afterSweep, ...snapshotPlayerPatch(base.host) }; + // 2) Shuffle check — no-op unless >= 15 min since last shuffle. + const afterShuffle = maybeShuffleQueue(afterSweep); - // 3) Commit locally + push remote. + // 3) Overlay the host's live playback snapshot. + const next: OrbitState = { ...afterShuffle, ...snapshotPlayerPatch(base.host) }; + + // 4) Commit locally + push remote. useOrbitStore.getState().setState(next); try { await writeOrbitState(sessionPlaylistId, next); diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index 60ed22c6..aedae1a6 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -513,6 +513,35 @@ export async function sweepGuestOutboxes(sid: string, hostUsername: string): Pro /** How long we consider a heartbeat still fresh. Longer than the guest tick so a single missed beat is tolerated. */ export const ORBIT_HEARTBEAT_ALIVE_MS = 30_000; +/** Shuffle cadence — queue is reshuffled once every interval. */ +export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000; + +/** + * Host helper — applies a Fisher-Yates shuffle to `state.queue` iff enough + * time has passed since the last shuffle. Pure, returns a new state object. + * `currentTrack` is never touched. + */ +export function maybeShuffleQueue(state: OrbitState, nowMs: number = Date.now()): OrbitState { + if (nowMs - state.lastShuffle < ORBIT_SHUFFLE_INTERVAL_MS) return state; + if (state.queue.length < 2) { + // Still bump `lastShuffle` so the next eligible shuffle is 15 min away, + // preventing a tight retry loop right after a guest drops a single item in. + return { ...state, lastShuffle: nowMs }; + } + const shuffled = state.queue.slice(); + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return { ...state, queue: shuffled, lastShuffle: nowMs }; +} + +/** Drift between a guest's local playback and the host's estimated live position. */ +export function computeOrbitDriftMs(state: OrbitState, guestPositionMs: number, nowMs: number = Date.now()): number { + const hostEstimated = state.positionMs + (state.isPlaying ? (nowMs - state.positionAt) : 0); + return guestPositionMs - hostEstimated; +} + /** * Fold sweep results into an updated `OrbitState`. * From 9e1256e2009409c8e2e6700e72b2ad29e6e5bcc9 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Thu, 23 Apr 2026 01:07:40 +0200 Subject: [PATCH 06/53] exp(orbit): mount hooks and session top strip Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 8 ++ src/components/OrbitSessionBar.tsx | 148 +++++++++++++++++++++++++++++ src/styles/components.css | 139 +++++++++++++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 src/components/OrbitSessionBar.tsx diff --git a/src/App.tsx b/src/App.tsx index 43b989be..a1c685e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -62,6 +62,9 @@ import GenreDetail from './pages/GenreDetail'; import ExportPickerModal from './components/ExportPickerModal'; import AppUpdater from './components/AppUpdater'; import TitleBar from './components/TitleBar'; +import OrbitSessionBar from './components/OrbitSessionBar'; +import { useOrbitHost } from './hooks/useOrbitHost'; +import { useOrbitGuest } from './hooks/useOrbitGuest'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform'; import { version } from '../package.json'; import { useConnectionStatus } from './hooks/useConnectionStatus'; @@ -129,6 +132,10 @@ function AppShell() { const [isWindowFullscreen, setIsWindowFullscreen] = useState(false); const [isTilingWm, setIsTilingWm] = useState(false); + // Orbit session hooks: idle until the local store marks a role. + useOrbitHost(); + useOrbitGuest(); + useEffect(() => { if (!IS_LINUX) return; invoke('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {}); @@ -407,6 +414,7 @@ function AppShell() { onContextMenu={e => e.preventDefault()} > {IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && } + {!isMobile && ( s.state); + const role = useOrbitStore(s => s.role); + const phase = useOrbitStore(s => s.phase); + const [nowMs, setNowMs] = useState(() => Date.now()); + + // Second-level tick just for the shuffle countdown + drift readout — + // the store itself only ticks at 2.5 s which is too coarse for a smooth + // countdown. + useEffect(() => { + if (!state || phase !== 'active') return; + const id = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(id); + }, [state, phase]); + + if (!state || (phase !== 'active' && phase !== 'ended')) return null; + + const untilShuffle = Math.max(0, (state.lastShuffle + ORBIT_SHUFFLE_INTERVAL_MS) - nowMs); + + // Guest-only: detect drift from the host's estimated live position. + const guestPlayback = usePlayerStore.getState(); + const localPositionMs = Math.round((guestPlayback.currentTime ?? 0) * 1000); + const driftMs = role === 'guest' && state.currentTrack && guestPlayback.currentTrack?.id === state.currentTrack.trackId + ? computeOrbitDriftMs(state, localPositionMs, nowMs) + : null; + const showCatchUp = role === 'guest' + && state.isPlaying + && state.currentTrack + && (driftMs == null || Math.abs(driftMs) > CATCH_UP_DRIFT_THRESHOLD_MS); + + const onExit = async () => { + try { + if (role === 'host') await endOrbitSession(); + else if (role === 'guest') await leaveOrbitSession(); + else useOrbitStore.getState().reset(); + } catch { + useOrbitStore.getState().reset(); + } + }; + + const onCatchUp = async () => { + if (!state.currentTrack) return; + const trackId = state.currentTrack.trackId; + const targetMs = estimateLivePosition(state, Date.now()); + const targetSec = Math.max(0, targetMs / 1000); + try { + const song = await getSong(trackId); + if (!song) return; + const track = songToTrack(song); + const player = usePlayerStore.getState(); + if (player.currentTrack?.id === trackId) { + // Same track: just seek + resume. + player.seek(targetSec / Math.max(1, track.duration)); + if (!player.isPlaying) player.resume(); + } else { + // Different track: play + seek on next tick once engine is ready. + player.playTrack(track, [track]); + // Best-effort: seek to the host's position a beat later. + window.setTimeout(() => { + const p = usePlayerStore.getState(); + if (p.currentTrack?.id === trackId) { + p.seek(targetSec / Math.max(1, track.duration)); + } + }, 400); + } + } catch { + // silent — if the track is gone from the host's library, nothing we can do. + } + }; + + const participantCount = state.participants.length + 1; // +1 for the host + + return ( +
+
+
+ +
+ + 🔀 {formatCountdown(untilShuffle)} + +
+ +
+ {showCatchUp && ( + + )} + +
+
+ ); +} diff --git a/src/styles/components.css b/src/styles/components.css index fd2b0dcd..190d6fa0 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -11180,3 +11180,142 @@ html[data-app-hidden="true"] *::after { .np-dash-stats-values { gap: 16px; } .np-dash-stat-value { font-size: 18px; } } + +/* ───────────────────────────────────────────────────────────────────── + Orbit — session top strip + ───────────────────────────────────────────────────────────────────── */ + +.orbit-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 500; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + padding: 6px 14px; + background: color-mix(in srgb, var(--ctp-base, #1e1e2e) 80%, transparent); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border, rgba(255,255,255,0.08))); + color: var(--text-primary); + font-size: 12px; + letter-spacing: 0.01em; + animation: orbit-bar-in 260ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +@keyframes orbit-bar-in { + from { opacity: 0; transform: translateY(-100%); } + to { opacity: 1; transform: translateY(0); } +} + +.orbit-bar__left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.orbit-bar__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 70%, transparent); + animation: orbit-pulse 2s ease-in-out infinite; + flex-shrink: 0; +} + +@keyframes orbit-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(0.85); } +} + +.orbit-bar__name { + font-weight: 600; + color: var(--accent); + overflow: hidden; + text-overflow: ellipsis; +} + +.orbit-bar__sep { + color: rgba(255, 255, 255, 0.35); + flex-shrink: 0; +} + +.orbit-bar__count, +.orbit-bar__host { + color: var(--text-muted); + flex-shrink: 0; +} + +.orbit-bar__center { + display: flex; + align-items: center; + justify-content: center; +} + +.orbit-bar__shuffle { + font-variant-numeric: tabular-nums; + color: var(--text-muted); + font-weight: 500; + padding: 3px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--text-primary) 5%, transparent); + border: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent); +} + +.orbit-bar__right { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.orbit-bar__catchup { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 16%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent); + color: var(--accent); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease, transform 120ms ease; +} +.orbit-bar__catchup:hover { + background: color-mix(in srgb, var(--accent) 26%, transparent); + border-color: color-mix(in srgb, var(--accent) 55%, transparent); + transform: translateY(-1px); +} + +.orbit-bar__exit { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + background: color-mix(in srgb, var(--text-primary) 5%, transparent); + border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent); + color: var(--text-muted); + cursor: pointer; + transition: color 150ms ease, background 150ms ease, transform 180ms ease; +} +.orbit-bar__exit:hover { + color: var(--ctp-red, #f38ba8); + background: color-mix(in srgb, var(--ctp-red, #f38ba8) 12%, transparent); + transform: rotate(90deg); +} + +/* Push the rest of the shell down while the bar is visible. */ +.app-shell:has(.orbit-bar) { padding-top: 32px; } From dc82e49bd1a83f96f724a7ccad8560fee7beb61e Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Thu, 23 Apr 2026 01:13:00 +0200 Subject: [PATCH 07/53] exp(orbit): participants list, kick flow, exit modals Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/OrbitExitModal.tsx | 59 ++++++++++ src/components/OrbitParticipantsPopover.tsx | 98 ++++++++++++++++ src/components/OrbitSessionBar.tsx | 37 +++++- src/styles/components.css | 124 ++++++++++++++++++++ src/utils/orbit.ts | 44 +++++++ 5 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 src/components/OrbitExitModal.tsx create mode 100644 src/components/OrbitParticipantsPopover.tsx diff --git a/src/components/OrbitExitModal.tsx b/src/components/OrbitExitModal.tsx new file mode 100644 index 00000000..23fa1990 --- /dev/null +++ b/src/components/OrbitExitModal.tsx @@ -0,0 +1,59 @@ +import { createPortal } from 'react-dom'; +import { useOrbitStore } from '../store/orbitStore'; +import { leaveOrbitSession } from '../utils/orbit'; + +/** + * Orbit — exit notification modal. + * + * Shown when: + * - `phase === 'ended'` (host closed the session; guest sees it) + * - `phase === 'error' && errorMessage === 'kicked'` (host removed us) + * + * "OK" cleans up the guest-side outbox + resets the local store. + */ +export default function OrbitExitModal() { + const phase = useOrbitStore(s => s.phase); + const errorMessage = useOrbitStore(s => s.errorMessage); + const role = useOrbitStore(s => s.role); + const sessionName = useOrbitStore(s => s.state?.name); + const hostName = useOrbitStore(s => s.state?.host); + + const isEnded = phase === 'ended'; + const isKicked = phase === 'error' && errorMessage === 'kicked'; + if (!isEnded && !isKicked) return null; + + const title = isKicked + ? 'You were removed from the session' + : 'The host ended the session'; + const body = isKicked + ? `@${hostName ?? 'host'} removed you from "${sessionName ?? 'the session'}".` + : `"${sessionName ?? 'The session'}" has ended. Hope you had fun.`; + + const onOk = async () => { + try { + if (role === 'guest') await leaveOrbitSession(); + else useOrbitStore.getState().reset(); + } catch { + useOrbitStore.getState().reset(); + } + }; + + return createPortal( +
{ if (e.target === e.currentTarget) onOk(); }} + role="dialog" + aria-modal="true" + aria-labelledby="orbit-exit-title" + > +
+

{title}

+

{body}

+
+ +
+
+
, + document.body, + ); +} diff --git a/src/components/OrbitParticipantsPopover.tsx b/src/components/OrbitParticipantsPopover.tsx new file mode 100644 index 00000000..655853a7 --- /dev/null +++ b/src/components/OrbitParticipantsPopover.tsx @@ -0,0 +1,98 @@ +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { Crown, UserMinus } from 'lucide-react'; +import { useOrbitStore } from '../store/orbitStore'; +import { kickOrbitParticipant } from '../utils/orbit'; + +interface Props { + /** Anchor — we position the popover directly below its bottom-right. */ + anchorRef: React.RefObject; + onClose: () => void; +} + +function joinedFor(fromMs: number, nowMs: number): string { + const sec = Math.max(0, Math.round((nowMs - fromMs) / 1000)); + if (sec < 60) return `${sec}s`; + const m = Math.floor(sec / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}h${rm.toString().padStart(2, '0')}`; +} + +export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) { + const state = useOrbitStore(s => s.state); + const role = useOrbitStore(s => s.role); + const popRef = useRef(null); + const nowMs = Date.now(); + + // Close on outside click / Escape. + useEffect(() => { + const onDown = (e: MouseEvent) => { + const t = e.target as Node | null; + if (popRef.current?.contains(t)) return; + if (anchorRef.current?.contains(t)) return; + onClose(); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [anchorRef, onClose]); + + if (!state) return null; + + const anchor = anchorRef.current?.getBoundingClientRect(); + const style: React.CSSProperties = anchor + ? { + position: 'fixed', + top: anchor.bottom + 6, + left: Math.max(8, anchor.left - 100), + zIndex: 9999, + } + : { display: 'none' }; + + const onKick = (username: string) => { + void kickOrbitParticipant(username); + }; + + return createPortal( +
+
+ {state.participants.length + 1} in session +
+ +
+ + @{state.host} + host +
+ + {state.participants.length === 0 && ( +
No guests yet
+ )} + + {state.participants.map(p => ( +
+ @{p.user} + {joinedFor(p.joinedAt, nowMs)} + {role === 'host' && ( + + )} +
+ ))} +
, + document.body, + ); +} diff --git a/src/components/OrbitSessionBar.tsx b/src/components/OrbitSessionBar.tsx index f7c106a2..2e3eb89c 100644 --- a/src/components/OrbitSessionBar.tsx +++ b/src/components/OrbitSessionBar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { X, RefreshCw } from 'lucide-react'; import { useOrbitStore } from '../store/orbitStore'; import { usePlayerStore, songToTrack } from '../store/playerStore'; @@ -10,6 +10,8 @@ import { } from '../utils/orbit'; import { ORBIT_SHUFFLE_INTERVAL_MS } from '../utils/orbit'; import { estimateLivePosition } from '../api/orbit'; +import OrbitParticipantsPopover from './OrbitParticipantsPopover'; +import OrbitExitModal from './OrbitExitModal'; /** * Orbit — top-strip session indicator. @@ -36,7 +38,10 @@ export default function OrbitSessionBar() { const state = useOrbitStore(s => s.state); const role = useOrbitStore(s => s.role); const phase = useOrbitStore(s => s.phase); + const errorMessage = useOrbitStore(s => s.errorMessage); const [nowMs, setNowMs] = useState(() => Date.now()); + const [peopleOpen, setPeopleOpen] = useState(false); + const peopleBtnRef = useRef(null); // Second-level tick just for the shuffle countdown + drift readout — // the store itself only ticks at 2.5 s which is too coarse for a smooth @@ -47,7 +52,15 @@ export default function OrbitSessionBar() { return () => window.clearInterval(id); }, [state, phase]); - if (!state || (phase !== 'active' && phase !== 'ended')) return null; + // Bar is visible while active, ended (pre-ack), or explicitly kicked. + const shouldShowBar = !!state && ( + phase === 'active' + || phase === 'ended' + || (phase === 'error' && errorMessage === 'kicked') + ); + if (!shouldShowBar || !state) return ( + + ); const untilShuffle = Math.max(0, (state.lastShuffle + ORBIT_SHUFFLE_INTERVAL_MS) - nowMs); @@ -110,7 +123,17 @@ export default function OrbitSessionBar() {