mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
docs(orbit): add ORBIT.md with user guide + technical architecture
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
# 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_<sid>` | host | Canonical session state (4 KB JSON blob) in the playlist **comment**. Track list is always empty. |
|
||||
| `__psyorbit_<sid>_from_<user>__` | 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_<sid>_from_<user>__` 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.
|
||||
Reference in New Issue
Block a user