# Psy Orbit A "listen together" mode built into Psysonic. One participant hosts the music, others tune in and listen in sync. Guests can suggest tracks; the host decides what lands in the queue. No external servers, no relays, no accounts on yet another platform — Orbit piggybacks entirely on your existing Navidrome instance. Sessions live in regular playlists (with a small JSON blob in the comment field), the clients poll and write to those playlists, and that's it. --- ## Table of contents - [For users](#for-users) - [Starting a session (host)](#starting-a-session-host) - [Joining (guest)](#joining-guest) - [Suggesting tracks](#suggesting-tracks) - [Approvals](#approvals) - [Shared queue](#shared-queue) - [Session settings](#session-settings) - [Participants](#participants) - [Ending the session](#ending-the-session) - [Requirements & limits](#requirements--limits) - [How it works (technical)](#how-it-works-technical) - [Design goals](#design-goals) - [Playlists as transport](#playlists-as-transport) - [The host tick](#the-host-tick) - [The guest tick](#the-guest-tick) - [Data flow](#data-flow) - [State shape](#state-shape) - [Cleanup](#cleanup) - [Security & privacy](#security--privacy) - [Edge cases handled](#edge-cases-handled) - [Code map](#code-map) --- ## For users ### Starting a session (host) Click **Psy Orbit** in the top bar → **Create a session**. The start modal opens with: - **Session name** — a random playful name is generated; edit it or reroll with the dice button. - **Max guests** — cap on concurrent participants (1–32). You don't count. - **Invite link** — ready to copy and share the moment the modal opens. Pre-generated from a fresh session id + the slugified name. - **Clear my queue first** — optional. Start with an empty queue (guest suggestions land fresh) vs. keep your current queue and share it with the guests. Click **Start Orbit**. The session bar appears at the top of the window (session name, participant count, shuffle countdown, settings / share / help / exit buttons). The link is now live — share it. ### Joining (guest) Two equivalent paths: 1. **Paste anywhere** — copy the invite link the host sent you. Anywhere in Psysonic (not inside a text field), press `Ctrl+V` (`Cmd+V` on macOS). A confirm dialog shows who invited you; click Join. 2. **Launch popover** — click **Psy Orbit** in the top bar → **Join a session** → paste the link into the field → Join. Either path performs the same preflight: validates the link, checks the session still exists, handles server switches automatically if the link points at another Navidrome you have an account for. If you have **multiple accounts** on the target server, a small picker asks which one to join as. ### Suggesting tracks Anywhere a song row appears (album, playlist, favorites, artist top-songs, search results, random mix, advanced search): - **Double-click** a row → adds just that track. - **Right-click → "Add to Orbit session"** — same effect, via context menu. - **Single-click** on a row shows a toast hint: "Double-click to add". This is deliberate — a single click normally drops the whole album into your queue, which would spam the shared queue and annoy everyone. Explicit bulk buttons (**Play All** / **Play Album** / **Play Playlist** / Hero play / Album-card play) ask for a confirmation first inside an active session. On confirm, the tracks are **appended** to the shared queue, never replacing it. ### Approvals By default, a new session starts with **auto-approve off**. Guest suggestions land in the session's suggestion history but not the actual playback queue — the host decides. The host sees a prominent **Pending approvals** strip at the top of the queue panel: each pending track with cover, title, artist, and "Suggested by …" line, plus two buttons: - ✓ Accept — enqueues the track into the host's player queue. Guests see it appear in the shared queue on the next tick. - ✕ Decline — drops the suggestion. It stays in the suggestion history for audit but won't show up again in the approval strip. Auto-approve can be toggled on in the session settings for any session where manual approval isn't needed. ### Shared queue Both hosts and guests see a strip at the top of the queue panel with the session name and a comma-separated list of all participants (host first). Under that: - **Host** view: regular queue, with any new guest suggestions injected into random positions inside the upcoming range. - **Guest** view: read-only display of the host's upcoming queue (up to 30 tracks at a time) with submitter attribution — "by alice" for host-chosen tracks is omitted; "suggested by alice" is shown for guest suggestions. When the guest has in-flight suggestions that haven't been merged yet, they appear in a separate **Waiting for host** section above Up next, with a clock icon. Once the host (auto-)approves and merges, they move into the normal list. ### Session settings Open via the gear icon in the session bar (host only): - **Auto-approve suggestions** — on/off. Default off. - **Automatic reshuffle** — on/off. Periodically Fisher–Yates-shuffles the upcoming queue. - **Reshuffle every** — 1 / 5 / 10 / 15 / 30 min preset picker. Disabled when auto-reshuffle is off. - **Shuffle now** — one-shot manual shuffle + bumps the next-auto-shuffle timer. ### Participants Click the participant count in the session bar. Opens a popover: - Host row at the top with a crown icon. - Each connected guest with a user icon, username, and join timestamp. - Host-only actions per guest: **Remove** (drops them from the session, can re-join via the invite link) vs. **Ban** (permanently blocked for the lifetime of the session). Both confirm before firing. Guests see the same list but read-only — no action buttons. ### Ending the session - **Host clicks X** → confirm dialog → session closes for everyone. Server playlists are deleted automatically. - **Guest clicks X** → confirm dialog → just the guest leaves. Session continues for everyone else. - **Host goes silent for 5 minutes** (network drop, app crash, laptop lid) → guests auto-leave with a dedicated "Host went silent" modal. - **App close / force quit** → next app launch sweeps up any orphaned session playlists you own (`__psyorbit_*` with stale heartbeat). ### The help modal Every screen with the session bar has a `?` icon between settings and X that opens a 9-section walk-through of everything above, with keyboard navigation (arrow keys between sections, Enter to expand). --- ## Requirements & limits - **Same Navidrome server.** Everyone — host and all guests — must be logged into the same Navidrome instance. Orbit links encode the server URL, and Psysonic auto-switches on paste if you have an account there. - **Separate accounts.** Each participant needs their own Navidrome user. If a host and guest log in as the same user, their outbox playlists collide and suggestions get lost. This is a hard limit of the current design — Orbit identifies participants by username. - **Public server address for remote guests.** Guests outside your home network need your server reachable at a public hostname. The start modal warns you if you're currently connected via a LAN address. - **Host presence matters.** Guests auto-leave after 5 minutes of no host activity. Shorter reconnects (network blips, phone screen off, whatever) are invisible. - **Session size.** State is bounded to ~4 KB per playlist comment. In practice that's plenty for a session name, participants list, and a suggestion history; there's no hard cap on tracks through the actual playback queue. --- ## How it works (technical) ### Design goals 1. **No external infrastructure.** Everything runs on your Navidrome. No relay, no auth server, no persistent state anywhere you don't already own. 2. **No protocol changes.** Uses Navidrome's existing Subsonic/OpenSubsonic playlist endpoints. If your server can host a normal playlist, it can host an Orbit session. 3. **Degrade gracefully.** A dropped tick doesn't break a session. Network blips are silent. Missing heartbeats expire cleanly. Crashes clean up on the next launch. 4. **Host-authoritative.** The host's player is the ground truth; guests mirror. No distributed consensus, no leader election. ### Playlists as transport Every session creates two kinds of playlists on the server (names are stable and start with `__psyorbit_`): | Playlist | Who owns it | What's in it | |---|---|---| | `__psyorbit_` | host | Canonical session state (4 KB JSON blob) in the playlist **comment**. Track list is always empty. | | `__psyorbit__from___` | each participant | Outbox. Comment holds a heartbeat timestamp; the track list holds pending guest suggestions. | All playlists are marked `public: true` so every participant can read them via the normal Subsonic endpoints (`getPlaylist.view`, `getPlaylists.view`). Psysonic filters `__psyorbit_*` out of its own UI (Playlists page, pickers, context menu), but the Navidrome web client will show them while a session is active. ### The host tick Fired every 2.5 s from `useOrbitHost`: 1. **Sweep all outboxes.** List every `__psyorbit__from___` playlist. For each one, read the current tracklist (= new suggestions from that guest) and the heartbeat timestamp from the comment. 2. **Apply snapshots to state.** Rebuild the `participants` array from heartbeat freshness (anyone with a heartbeat < 30 s old is "alive"). Append new suggestions to `state.queue` as `OrbitQueueItem { trackId, addedBy, addedAt }`. 3. **Clear each swept outbox's tracklist** (heartbeat stays). Single-pass consume — a track the host has seen is the host's problem now, not the outbox's. 4. **Merge into player queue** (when auto-approve is on, and the suggestion isn't host-authored or already merged). Each merged track gets sprinkled at a random position in the upcoming range so host picks and guest suggestions interleave. 5. **Maybe shuffle.** If auto-shuffle is on and the interval elapsed, Fisher–Yates-reorder the upcoming play queue + rewrite `state.lastShuffle`. 6. **Snapshot playback.** Write `isPlaying`, `positionMs`, `positionAt` (wall-clock), `currentTrack`, and a 30-item slice of the upcoming play queue (`playQueue`) into the state blob. 7. **Write.** Serialise and push to the session playlist's comment via `updatePlaylist.view`. Host also writes a heartbeat to its own outbox every 10 s so the participants pipeline treats the host symmetrically. ### The guest tick Fired from `useOrbitGuest` — fast polling (500 ms) until the first successful sync lands, then steady 2.5 s: 1. **Read the session playlist comment** via `getPlaylist.view`. Parse the OrbitState. 2. **Check for session death:** comment empty → session-ended; `state.ended === true` → session-ended; `state.positionAt` older than 5 min → host-timeout. 3. **Check kick / remove:** if the local username is in `state.kicked` or has a fresh entry in `state.removed`, transition to the appropriate exit modal. 4. **Reconcile pending suggestions.** For every trackId the local client has submitted, check if it's appeared in `state.playQueue` or `state.currentTrack`. If so, drop it from the local "pending" list (the UI hides it automatically). 5. **Auto-sync to host.** Three cases: - Different track at host → load it locally (`playTrack`), seek to `estimateLivePosition(state, now)`, mirror `isPlaying`. Never touches the local player if the guest has locally diverged (paused on their own). - Same track, play/pause flipped at host → mirror only if the guest hasn't locally diverged since the last tick. - First tick after join → mirror unconditionally (initial sync). 6. **Heartbeat tick** (independent, every 10 s): write `{ ts: Date.now() }` into the guest outbox comment. ### Data flow ``` Host (per tick) Navidrome Guest (per tick) ────────────────────────────────────────────────────────────────────────────────── player.currentTrack + position │ ▼ snapshotPlayerPatch ──► writeOrbitState ─┐ │ ┌─session playlist─┐ │ comment = JSON │ ◄─readOrbitState └──────────────────┘ │ ▼ parse OrbitState │ ▼ syncToHost: • getSong • playTrack • seek • resume/pause Guest suggests track Y ┌────────────────────────────────────────────────────────────┤ │ │ ▼ │ suggestOrbitTrack ┌──guest outbox──┐ │ track list = Y │ ◄─updatePlaylist └────────────────┘ │ │ Host: sweepGuestOutboxes ◄──────────┘ │ ▼ applyOutboxSnapshotsToState (queue += Y, participants refreshed) │ ▼ (if auto-approve) mergeNewSuggestionsIntoQueue │ ▼ player.enqueueAt ──► playQueue snapshot ──► session playlist ──► Guest reconciles pending list ``` ### State shape All relevant types in `src/api/orbit.ts`: ```ts interface OrbitState { v: 3; sid: string; // 8 hex chars host: string; // navidrome username name: string; // human-readable session name started: number; // ms since epoch maxUsers: number; currentTrack: OrbitQueueItem | null; isPlaying: boolean; positionMs: number; positionAt: number; // wall-clock ms of the last snapshot queue: OrbitQueueItem[]; // suggestion history playQueue: OrbitQueueItem[]; // 30-item slice of host's upcoming playQueueTotal: number; participants: OrbitParticipant[]; kicked: string[]; removed: { user: string; at: number }[]; lastShuffle: number; settings: { autoApprove: boolean; autoShuffle: boolean; shuffleIntervalMin: 1 | 5 | 10 | 15 | 30; }; ended: boolean; } interface OrbitQueueItem { trackId: string; addedBy: string; // navidrome username addedAt: number; } ``` The state blob is size-bounded to 4 KB (serialised JSON). `serialiseOrbitState` throws `OrbitStateTooLarge` above the budget so callers can trim optional fields and retry. ### Cleanup Three layers of defense against orphaned playlists: 1. **Explicit exit.** `endOrbitSession` (host) or `leaveOrbitSession` (guest) deletes the participant's own playlists synchronously. The happy path. 2. **Server-switch teardown.** Switching Navidrome servers tears the current session down first (up to 1.5 s), then switches. Prevents "in session against wrong server" states. 3. **App-start orphan sweep.** Every app launch runs `cleanupOrphanedOrbitPlaylists`: lists every `__psyorbit_*` playlist the current user owns, parses the heartbeat from the comment, deletes anything with a heartbeat older than 5 minutes (or `ended: true`, or an unparseable comment). The current local session is always protected. The 5-minute TTL is a conservative compromise: long enough to survive a brief app restart (and a session running on another device of yours), short enough that a dead session doesn't clutter the server indefinitely. ### Security & privacy - **Authentication.** Uses Navidrome's own user system. Participants are identified by their username; no additional auth layer. - **Public playlist visibility.** Session and outbox playlists must be `public: true` so guests can read them. Side effect: they're visible to *any* user on the same Navidrome instance while the session is active. Psysonic's own UI filters them; the Navidrome web client does not. - **No external servers.** Orbit is strictly peer-to-peer via the Navidrome instance you already trust. No data leaves your server. - **No message signing.** Since everything is owned by authenticated Navidrome users, we rely on the server's own ACLs. A guest can't modify the host's session playlist (different owner), only their own outbox. - **Track IDs only.** The state blob references tracks by their Navidrome ID. No filenames, no paths, no stream URLs. --- ## Edge cases handled - **Host offline < 15 s.** Silent. Guests extrapolate via `estimateLivePosition` (positionMs + elapsed wall-clock). - **Host offline 15 s – 5 min.** Guest UI shows a yellow "Host offline" badge next to the session name. Playback continues locally. - **Host offline > 5 min.** Guest auto-leaves with a "Host went silent" modal. Cleanup of guest outbox runs on dismissal. - **Guest pauses locally.** The guest's local pause survives host track changes — the next-track event won't silently un-pause them. "Catch up" brings them back in sync. - **Guest resume in orbit.** Pressing play (player bar, media keys, MPRIS) in an active session is interpreted as "catch up" — loads the host's current track and seeks to the live position, not "resume the locally frozen track". - **Bulk "Play All" in-session.** Dialog: "Add 14 tracks to the Orbit queue?" On confirm, appended. On cancel, no-op. - **Single-click on song row in-session.** Swallowed; shows "Double-click to add" toast. - **Multiple accounts on target server.** Paste flow opens an account picker modal. Keyboard-navigable. - **Server switch while in session.** Teardown runs before switch. Any server-resident session playlists get cleaned up by their host's next app-start sweep. - **Initial sync race.** The guest's first tick retries on 500 ms cadence until the player state actually matches the host's last-known track (up to 2 s per attempt, then falls through with a best-effort mirror). - **`positionAt` stale on join.** Seek fraction is clamped to [0, 0.99] — prevents `audio:ended` from firing at the very start of a join. - **Outbox deletion mid-session** (cleanup race): host sees the guest drop out on the next sweep; guest's next heartbeat recreates the outbox if they're still connected. - **Session playlist deleted** (cleanup race while the guest's local store says it's still active): guest treats as "ended", shows the exit modal. --- ## Code map ### State and types - `src/api/orbit.ts` — `OrbitState`, `OrbitQueueItem`, `OrbitSettings`, serialise/parse helpers, `estimateLivePosition`. - `src/store/orbitStore.ts` — local session state: role, phase, session/playlist ids, `pendingSuggestions`, `mergedSuggestionKeys`, `declinedSuggestionKeys`. ### Lifecycle - `src/utils/orbit.ts` — `startOrbitSession`, `joinOrbitSession`, `endOrbitSession`, `leaveOrbitSession`, `suggestOrbitTrack`, `approveOrbitSuggestion`, `declineOrbitSuggestion`, `hostEnqueueToOrbit`, `cleanupOrphanedOrbitPlaylists`, `effectiveShuffleIntervalMs`. - `src/utils/orbitBulkGuard.ts` — standalone confirm-dialog helper invoked from `playerStore` when `>1` tracks land in the queue while a session is active. - `src/utils/switchActiveServer.ts` — wires Orbit teardown into server-switch. ### Hooks - `src/hooks/useOrbitHost.ts` — host state tick + outbox sweep + merge pipeline + heartbeat. - `src/hooks/useOrbitGuest.ts` — guest state pull + auto-sync + heartbeat + host-timeout detection. - `src/hooks/useOrbitSongRowBehavior.ts` — shared double-click-to-add behaviour for song lists. ### UI — session bar and popovers - `src/components/OrbitSessionBar.tsx` — topbar strip with name, counts, shuffle timer, settings/share/help/catch-up/exit buttons. - `src/components/OrbitSettingsPopover.tsx` — host settings (auto-approve, auto-shuffle, interval, manual shuffle). - `src/components/OrbitSharePopover.tsx` — host-only invite-link popover with copy button. - `src/components/OrbitParticipantsPopover.tsx` — participant list with kick/ban (host-only actions). ### UI — modals - `src/components/OrbitStartModal.tsx` — session creation wizard. - `src/components/OrbitJoinModal.tsx` — manual invite-link paste + join. - `src/components/OrbitAccountPicker.tsx` — multi-account disambiguation when joining. - `src/components/OrbitExitModal.tsx` — session-ended / kicked / removed / host-timeout exit notice. - `src/components/OrbitHelpModal.tsx` — 9-section help walk-through (keyboard-navigable). - `src/components/OrbitStartTrigger.tsx` — "Psy Orbit" button in the header + launch popover (create / join / help). ### UI — queue views - `src/components/OrbitQueueHead.tsx` — shared header strip (session name, participants, host-presence badge). - `src/components/OrbitGuestQueue.tsx` — guest-side queue view (current track, pending suggestions, upcoming). - `src/components/HostApprovalQueue.tsx` — host-side approval strip with accept/decline. ### Supporting - `src/store/confirmModalStore.ts` + `src/components/GlobalConfirmModal.tsx` — promise-based confirm dialog used by the bulk-gate. - `src/store/helpModalStore.ts` + `src/components/OrbitHelpModal.tsx` — shared help-modal state. - `src/store/orbitAccountPickerStore.ts` + `src/components/OrbitAccountPicker.tsx` — account picker for multi-account server switch.