diff --git a/ORBIT.md b/ORBIT.md new file mode 100644 index 00000000..24b15dd2 --- /dev/null +++ b/ORBIT.md @@ -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_` | 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. diff --git a/src/App.tsx b/src/App.tsx index 2cc9070d..0e2a5000 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,9 @@ import FullscreenPlayer from './components/FullscreenPlayer'; import ContextMenu from './components/ContextMenu'; import SongInfoModal from './components/SongInfoModal'; import DownloadFolderModal from './components/DownloadFolderModal'; +import GlobalConfirmModal from './components/GlobalConfirmModal'; +import OrbitAccountPicker from './components/OrbitAccountPicker'; +import OrbitHelpModal from './components/OrbitHelpModal'; import { DragDropProvider } from './contexts/DragDropContext'; import TooltipPortal from './components/TooltipPortal'; import OverlayScrollArea from './components/OverlayScrollArea'; @@ -65,6 +68,12 @@ 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 OrbitStartTrigger from './components/OrbitStartTrigger'; +import { useOrbitHost } from './hooks/useOrbitHost'; +import { useOrbitGuest } from './hooks/useOrbitGuest'; +import { cleanupOrphanedOrbitPlaylists, endOrbitSession, leaveOrbitSession } from './utils/orbit'; +import { useOrbitStore } from './store/orbitStore'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform'; import { version } from '../package.json'; import { useConnectionStatus } from './hooks/useConnectionStatus'; @@ -132,6 +141,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(() => {}); @@ -237,6 +250,14 @@ function AppShell() { }; }, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]); + // Orbit orphan sweep — delete our own leftover session / outbox playlists + // from crashed or force-closed sessions so they don't clutter the ND + // playlist view. Runs once per login; safe and best-effort. + useEffect(() => { + if (!isLoggedIn || !activeServerId) return; + void cleanupOrphanedOrbitPlaylists(); + }, [isLoggedIn, activeServerId]); + // Reset scroll position on route change (main viewport is overlay scroll) useEffect(() => { document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 }); @@ -422,6 +443,7 @@ function AppShell() { + + {connStatus === 'disconnected' && ( )} @@ -500,6 +523,9 @@ function AppShell() { + + + @@ -942,6 +968,19 @@ function TauriEventBridge() { await invoke('pause_rendering').catch(() => {}); await getCurrentWindow().hide(); } else { + // Clean up an active Orbit session before we go down — leaving + // the session playlists behind would litter the server. Capped at + // 1500 ms so a slow server can't keep the app hanging on quit; the + // next launch's orphan sweep is the safety net for anything that + // didn't make it out in time. + const role = useOrbitStore.getState().role; + if (role === 'host' || role === 'guest') { + const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession(); + await Promise.race([ + teardown.catch(() => {}), + new Promise(r => setTimeout(r, 1500)), + ]); + } await invoke('exit_app'); } }); @@ -1068,6 +1107,7 @@ export default function App() { return ( + ); diff --git a/src/api/orbit.ts b/src/api/orbit.ts new file mode 100644 index 00000000..f98612ec --- /dev/null +++ b/src/api/orbit.ts @@ -0,0 +1,223 @@ +/** + * 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[]; + /** + * Snapshot of the host's actual upcoming play queue (everything after + * `queueIndex`), capped at `ORBIT_PLAY_QUEUE_LIMIT` to fit the state-blob + * byte budget. Used by the guest view so guests see what's next in the + * host's player rather than just the suggestions backlog. `addedBy` + * carries the original suggester when known, otherwise the host. + */ + playQueue?: { trackId: string; addedBy: string }[]; + /** + * Total length of the host's upcoming play queue, even when `playQueue` + * was truncated. Lets the guest UI render a "+ N more" hint. + */ + playQueueTotal?: number; + /** 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[]; + /** + * Soft-removed users — short-lived markers (TTL `ORBIT_REMOVED_TTL_MS`) + * so the affected guest's next poll surfaces a "you were removed" modal. + * Unlike `kicked`, the user is NOT blocked from re-joining via the + * invite link. Aged out by the host's sweep tick. + */ + removed?: { user: string; at: number }[]; + /** Set when the host has ended the session; guests should exit on next poll. */ + ended?: boolean; + /** Host-settable session rules; absent on older clients — treat missing as all-defaults. */ + settings?: OrbitSettings; + /** + * Usernames muted by the host: their outbox is still polled (so heartbeats + * keep them visible as participants) but new track suggestions are dropped + * before they reach the approval list. Symmetric — host can re-enable. + */ + suggestionBlocked?: string[]; +} + +/** + * Host-configurable rules. All default to `true`, i.e. the feature runs + * "all on" for new sessions. Toggled via the Orbit-bar settings popover. + */ +/** Minute presets offered to the host in the Orbit settings popover. */ +export const ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN = [1, 5, 10, 15, 30] as const; +export type OrbitShuffleIntervalMin = typeof ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN[number]; + +export interface OrbitSettings { + /** Guest suggestions go straight into the host's play queue. */ + autoApprove: boolean; + /** Whether the auto-shuffle cycle runs at all. */ + autoShuffle: boolean; + /** + * Minutes between each auto-shuffle cycle. Must be one of + * `ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN`. Older sessions that predate this + * field fall back to 15 via `effectiveShuffleIntervalMs`. + */ + shuffleIntervalMin?: OrbitShuffleIntervalMin; +} + +export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = { + // Off by default — host decides per suggestion via the approval list. + autoApprove: false, + autoShuffle: true, + shuffleIntervalMin: 15, +}; + +/** 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; + +/** + * Hard cap on `playQueue` length. ~30 tracks × ~50 bytes each ≈ 1.5 KB, + * leaving room for the rest of the state blob under `ORBIT_STATE_MAX_BYTES`. + * Excess upcoming tracks are surfaced via the `playQueueTotal` count so the + * guest UI can show a "+ N more" hint instead of pretending there's nothing. + */ +export const ORBIT_PLAY_QUEUE_LIMIT = 30; + +/** + * 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: [], + removed: [], + suggestionBlocked: [], + playQueue: [], + playQueueTotal: 0, + settings: { ...ORBIT_DEFAULT_SETTINGS }, + }; +} + +/** + * 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. + // `removed` is optional (older hosts won't write it); coerce to [] if absent or malformed. + if (!Array.isArray(s.removed)) s.removed = []; + // `suggestionBlocked` is optional too — older hosts predate the mute feature. + if (!Array.isArray(s.suggestionBlocked)) s.suggestionBlocked = []; + // `playQueue` / `playQueueTotal` are optional (older hosts won't write them). + if (!Array.isArray(s.playQueue)) s.playQueue = []; + if (typeof s.playQueueTotal !== 'number') s.playQueueTotal = (s.playQueue?.length ?? 0); + 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/api/subsonic.ts b/src/api/subsonic.ts index ca3f8d15..fef0f0f1 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -1035,9 +1035,14 @@ export function buildDownloadUrl(id: string): string { } // ─── Playlists ──────────────────────────────────────────────── -export async function getPlaylists(): Promise { +export async function getPlaylists(includeOrbit = false): Promise { const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() }); - return data.playlists?.playlist ?? []; + const all = data.playlists?.playlist ?? []; + // Orbit session + outbox playlists are technical internals. They're `public` + // so guests can reach them, which means they leak into every UI picker and + // even into the Navidrome web client. Filter them out of every UI call; + // orbit's own sweep passes `includeOrbit=true`. + return includeOrbit ? all : all.filter(p => !p.name.startsWith('__psyorbit_')); } export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> { diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 5fc4567f..c79900d2 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -57,6 +57,8 @@ interface AlbumTrackListProps { userRatingOverrides: Record; starredSongs: Set; onPlaySong: (song: SubsonicSong) => void; + /** Optional dbl-click handler — currently set only in Orbit mode so the list knows to bind it. */ + onDoubleClickSong?: (song: SubsonicSong) => void; onRate: (songId: string, rating: number) => void; onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void; onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void; @@ -80,6 +82,7 @@ interface TrackRowProps { inSelectMode: boolean; isContextMenuSong: boolean; onPlaySong: (song: SubsonicSong) => void; + onDoubleClickSong?: (song: SubsonicSong) => void; onRate: (songId: string, rating: number) => void; onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void; onContextMenu: AlbumTrackListProps['onContextMenu']; @@ -100,6 +103,7 @@ const TrackRow = React.memo(function TrackRow({ inSelectMode, isContextMenuSong, onPlaySong, + onDoubleClickSong, onRate, onToggleSongStar, onContextMenu, @@ -224,6 +228,11 @@ const TrackRow = React.memo(function TrackRow({ onPlaySong(song); } }} + onDoubleClick={onDoubleClickSong ? e => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + if (e.ctrlKey || e.metaKey || inSelectMode) return; + onDoubleClickSong(song); + } : undefined} onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); @@ -266,6 +275,7 @@ export default function AlbumTrackList({ userRatingOverrides, starredSongs, onPlaySong, + onDoubleClickSong, onRate, onToggleSongStar, onContextMenu, @@ -644,6 +654,7 @@ export default function AlbumTrackList({ inSelectMode={inSelectMode} isContextMenuSong={contextMenuSongId === song.id} onPlaySong={onPlaySong} + onDoubleClickSong={onDoubleClickSong} onRate={onRate} onToggleSongStar={onToggleSongStar} onContextMenu={onContextMenu} diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index a6fb2722..a47d1557 100644 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -7,10 +7,15 @@ interface ConfirmModalProps { title: string; message: string; confirmLabel: string; - cancelLabel: string; + /** + * Cancel button label. Omit (together with `onCancel`) to render the + * modal as a single-button info dialog — Esc / outside-click / X then + * also resolve via `onConfirm`. + */ + cancelLabel?: string; danger?: boolean; onConfirm: () => void; - onCancel: () => void; + onCancel?: () => void; } export default function ConfirmModal({ @@ -23,15 +28,17 @@ export default function ConfirmModal({ onConfirm, onCancel, }: ConfirmModalProps) { + const dismiss = onCancel ?? onConfirm; + useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onCancel(); + if (e.key === 'Escape') dismiss(); else if (e.key === 'Enter') onConfirm(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [open, onCancel, onConfirm]); + }, [open, dismiss, onConfirm]); if (!open) return null; @@ -42,7 +49,7 @@ export default function ConfirmModal({ return createPortal(
e.stopPropagation()} style={{ maxWidth: '380px' }} > -

{title}

@@ -60,10 +67,12 @@ export default function ConfirmModal({ {message}

- - + )} +
diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 4601872d..e8a4824d 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,5 +1,12 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2 } from 'lucide-react'; +import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react'; +import { useOrbitStore } from '../store/orbitStore'; +import { + suggestOrbitTrack, + hostEnqueueToOrbit, + evaluateOrbitSuggestGate, + OrbitSuggestBlockedError, +} from '../utils/orbit'; import LastfmIcon from './LastfmIcon'; import StarRating from './StarRating'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; @@ -962,6 +969,7 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play export default function ContextMenu() { const { t } = useTranslation(); + const orbitRole = useOrbitStore(s => s.role); const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore( useShallow(s => ({ contextMenu: s.contextMenu, @@ -1454,6 +1462,38 @@ export default function ContextMenu() {
handleAction(() => enqueue([song]))}> {t('contextMenu.addToQueue')}
+ {orbitRole === 'guest' && (() => { + const muted = evaluateOrbitSuggestGate().reason === 'muted'; + return ( +
handleAction(() => { + if (muted) { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); return; } + suggestOrbitTrack(song.id) + .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) + .catch(err => { + if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') { + showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); + } else { + showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'); + } + }); + })} + > + {t('orbit.ctxAddToSession')} +
+ ); + })()} + {orbitRole === 'host' && ( +
handleAction(() => { + hostEnqueueToOrbit(song.id) + .then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info')) + .catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error')); + })}> + {t('orbit.ctxAddToSessionHost')} +
+ )}
handleAction(() => enqueue([song]))}> {t('contextMenu.addToQueue')}
+ {orbitRole === 'guest' && (() => { + const muted = evaluateOrbitSuggestGate().reason === 'muted'; + return ( +
handleAction(() => { + if (muted) { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); return; } + suggestOrbitTrack(song.id) + .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) + .catch(err => { + if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') { + showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); + } else { + showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'); + } + }); + })} + > + {t('orbit.ctxAddToSession')} +
+ ); + })()} + {orbitRole === 'host' && ( +
handleAction(() => { + hostEnqueueToOrbit(song.id) + .then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info')) + .catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error')); + })}> + {t('orbit.ctxAddToSessionHost')} +
+ )}
+ ); +} diff --git a/src/components/HostApprovalQueue.tsx b/src/components/HostApprovalQueue.tsx new file mode 100644 index 00000000..6f6779fe --- /dev/null +++ b/src/components/HostApprovalQueue.tsx @@ -0,0 +1,133 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Check, X, Inbox } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { + approveOrbitSuggestion, + declineOrbitSuggestion, + suggestionKey, +} from '../utils/orbit'; +import { + getSong, + buildCoverArtUrl, + coverArtCacheKey, + type SubsonicSong, +} from '../api/subsonic'; +import CachedImage from './CachedImage'; +import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit'; + +/** + * Host-only approval strip. Renders directly below the OrbitQueueHead + * when `autoApprove === false` and at least one guest suggestion is + * waiting. Shows each pending track with Approve / Decline controls. + * + * Only rendered by the host-side render path (QueuePanel); guests never + * see this section — they watch their own pending list. + */ +export default function HostApprovalQueue() { + const { t } = useTranslation(); + const role = useOrbitStore(s => s.role); + const state = useOrbitStore(s => s.state); + const mergedKeys = useOrbitStore(s => s.mergedSuggestionKeys); + const declinedKeys = useOrbitStore(s => s.declinedSuggestionKeys); + + const settings = state?.settings ?? ORBIT_DEFAULT_SETTINGS; + const autoApproveOff = settings.autoApprove === false; + + // Pending = everything in the session's suggestion history that isn't + // host-authored, isn't already merged, and hasn't been declined. + const pendingItems = useMemo(() => { + if (!state) return []; + const mergedSet = new Set(mergedKeys); + const declinedSet = new Set(declinedKeys); + return state.queue.filter(q => + q.addedBy !== state.host + && !mergedSet.has(suggestionKey(q)) + && !declinedSet.has(suggestionKey(q)) + ); + }, [state, mergedKeys, declinedKeys]); + + // Track-metadata cache (title/artist/cover) for the pending items. + const [songs, setSongs] = useState>({}); + const wantedKey = useMemo( + () => Array.from(new Set(pendingItems.map(q => q.trackId))).sort().join('|'), + [pendingItems], + ); + useEffect(() => { + const wanted = wantedKey ? wantedKey.split('|') : []; + const missing = wanted.filter(id => id && !songs[id]); + if (missing.length === 0) return; + let cancelled = false; + void Promise.all(missing.map(id => getSong(id).catch(() => null))) + .then(results => { + if (cancelled) return; + setSongs(prev => { + const next = { ...prev }; + results.forEach((s, i) => { if (s) next[missing[i]] = s; }); + return next; + }); + }); + return () => { cancelled = true; }; + }, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps + + if (role !== 'host' || !state || !autoApproveOff || pendingItems.length === 0) { + return null; + } + + return ( +
+
+ + {t('orbit.approvalTitle')} + {pendingItems.length} +
+
+ {pendingItems.map(q => { + const song = songs[q.trackId]; + const key = suggestionKey(q); + return ( +
+ {song?.coverArt ? ( + + ) : ( +
+ )} +
+
{song?.title ?? '…'}
+
{song?.artist ?? ''}
+
+ {t('orbit.approvalFrom', { user: q.addedBy })} +
+
+
+ + +
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/OrbitAccountPicker.tsx b/src/components/OrbitAccountPicker.tsx new file mode 100644 index 00000000..6358ec2b --- /dev/null +++ b/src/components/OrbitAccountPicker.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { X, User } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore'; + +/** + * Modal shown when joining an Orbit session and the user has more than + * one account for the target server URL. Lets them pick which account + * to switch to before the join flow continues. Mount once in App.tsx — + * any caller can invoke it via `useOrbitAccountPickerStore.request(...)`. + */ +export default function OrbitAccountPicker() { + const { t } = useTranslation(); + const { isOpen, accounts, pick, cancel } = useOrbitAccountPickerStore(); + const [selected, setSelected] = useState(0); + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + + // Reset + focus first item each time the picker re-opens. + useEffect(() => { + if (!isOpen) return; + setSelected(0); + // Defer focus to the next tick so the DOM has actually mounted. + queueMicrotask(() => itemRefs.current[0]?.focus()); + }, [isOpen]); + + // Move DOM focus with the arrow-key selection so the browser's focus + // ring follows, and the currently active button is readable to AT. + useEffect(() => { + if (!isOpen) return; + itemRefs.current[selected]?.focus(); + }, [selected, isOpen]); + + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { cancel(); return; } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelected(s => (s + 1) % Math.max(1, accounts.length)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelected(s => (s - 1 + accounts.length) % Math.max(1, accounts.length)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const target = accounts[selected]; + if (target) pick(target); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [isOpen, accounts, selected, pick, cancel]); + + if (!isOpen) return null; + + return createPortal( +
{ if (e.target === e.currentTarget) cancel(); }} + role="dialog" + aria-modal="true" + aria-labelledby="orbit-account-picker-title" + style={{ alignItems: 'center', paddingTop: 0 }} + > +
+ +

+ {t('orbit.accountPickerTitle')} +

+

+ {t('orbit.accountPickerSub', { url: accounts[0]?.url ?? '' })} +

+
    + {accounts.map((a, i) => ( +
  • + +
  • + ))} +
+
+ +
+
+
, + document.body, + ); +} diff --git a/src/components/OrbitExitModal.tsx b/src/components/OrbitExitModal.tsx new file mode 100644 index 00000000..e9ee4dd8 --- /dev/null +++ b/src/components/OrbitExitModal.tsx @@ -0,0 +1,87 @@ +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +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 permanently banned us) + * - `phase === 'error' && errorMessage === 'removed'` (host soft-removed us; + * re-join via invite link still works) + * + * "OK" cleans up the guest-side outbox + resets the local store. + */ +export default function OrbitExitModal() { + const { t } = useTranslation(); + 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'; + const isRemoved = phase === 'error' && errorMessage === 'removed'; + const isHostTimeout = phase === 'error' && errorMessage === 'host-timeout'; + const isOpen = isEnded || isKicked || isRemoved || isHostTimeout; + + const onOk = async () => { + try { + if (role === 'guest') await leaveOrbitSession(); + else useOrbitStore.getState().reset(); + } catch { + useOrbitStore.getState().reset(); + } + }; + + // Modal is informational with a single action — Enter / Escape both fire OK. + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); void onOk(); } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + if (!isOpen) return null; + + const title = isKicked + ? t('orbit.exitKickedTitle') + : isRemoved + ? t('orbit.exitRemovedTitle') + : isHostTimeout + ? t('orbit.exitHostTimeoutTitle') + : t('orbit.exitEndedTitle'); + const body = isKicked + ? t('orbit.exitKickedBody', { host: hostName ?? '', name: sessionName ?? '' }) + : isRemoved + ? t('orbit.exitRemovedBody', { host: hostName ?? '', name: sessionName ?? '' }) + : isHostTimeout + ? t('orbit.exitHostTimeoutBody', { host: hostName ?? '', name: sessionName ?? '' }) + : t('orbit.exitEndedBody', { name: sessionName ?? '' }); + + 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/OrbitGuestQueue.tsx b/src/components/OrbitGuestQueue.tsx new file mode 100644 index 00000000..1a2e9056 --- /dev/null +++ b/src/components/OrbitGuestQueue.tsx @@ -0,0 +1,192 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Radio, Clock } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { + getSong, + buildCoverArtUrl, + coverArtCacheKey, + type SubsonicSong, +} from '../api/subsonic'; +import CachedImage from './CachedImage'; +import OrbitQueueHead from './OrbitQueueHead'; + +/** + * Orbit — guest-side queue view. + * + * Rendered in place of the normal QueuePanel contents while `role === 'guest'`. + * Read-only: shows the host's current track + the host's actual upcoming + * play queue (`state.playQueue`, capped at `ORBIT_PLAY_QUEUE_LIMIT`). No + * reorder / remove / save — those belong to the host. + * + * Track metadata is resolved lazily via `getSong` and cached locally so the + * list doesn't flicker while the 2.5 s state tick refreshes the snapshot. + */ +export default function OrbitGuestQueue() { + const { t } = useTranslation(); + const state = useOrbitStore(s => s.state); + const pending = useOrbitStore(s => s.pendingSuggestions); + const queueItems = state?.playQueue ?? []; + const totalUpcoming = state?.playQueueTotal ?? queueItems.length; + const truncatedBy = Math.max(0, totalUpcoming - queueItems.length); + const currentTrack = state?.currentTrack ?? null; + + // Local song cache — keyed by trackId. Survives parent re-renders triggered + // by the store tick so list rows don't remount and recomputed URLs don't + // kick off duplicate `getSong` calls. + const [songs, setSongs] = useState>({}); + + // Track IDs we need but don't yet have. String-joined so useEffect deps + // stay stable across identical queue snapshots (e.g. reshuffle). + const wantedKey = useMemo(() => { + const ids: string[] = []; + if (currentTrack) ids.push(currentTrack.trackId); + queueItems.forEach(q => ids.push(q.trackId)); + pending.forEach(id => ids.push(id)); + return Array.from(new Set(ids)).sort().join('|'); + }, [currentTrack, queueItems, pending]); + + useEffect(() => { + const wanted = wantedKey ? wantedKey.split('|') : []; + const missing = wanted.filter(id => id && !songs[id]); + if (missing.length === 0) return; + + let cancelled = false; + void Promise.all(missing.map(id => getSong(id).catch(() => null))) + .then(results => { + if (cancelled) return; + setSongs(prev => { + const next = { ...prev }; + results.forEach((s, i) => { if (s) next[missing[i]] = s; }); + return next; + }); + }); + return () => { cancelled = true; }; + }, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!state) return null; + + const currentSong = currentTrack ? songs[currentTrack.trackId] : null; + + return ( +
+ + + {currentTrack && ( +
+
+ {t('orbit.guestLive')} +
+
+ {currentSong?.coverArt ? ( + + ) : ( +
+ )} +
+
+ {currentSong?.title ?? t('orbit.guestLoading')} +
+
+ {currentSong?.artist ?? ''} +
+
+ {state.isPlaying ? t('orbit.guestPlaying') : t('orbit.guestPaused')} +
+
+
+
+ )} + + {pending.length > 0 && ( +
+
+ + {t('orbit.guestPendingTitle')} + {pending.length} +
+ {pending.map(trackId => { + const song = songs[trackId]; + return ( +
+ {song?.coverArt ? ( + + ) : ( +
+ )} +
+
{song?.title ?? '…'}
+
{song?.artist ?? ''}
+
{t('orbit.guestPendingHint')}
+
+
+ ); + })} +
+ )} + +
+ {t('orbit.guestUpNext')} {totalUpcoming} +
+ +
+ {queueItems.length === 0 && ( +
{t('orbit.guestUpNextEmpty')}
+ )} + + {queueItems.map((q, i) => { + const song = songs[q.trackId]; + const isHostPick = q.addedBy === state.host; + return ( +
+ {song?.coverArt ? ( + + ) : ( +
+ )} +
+
+ {song?.title ?? '…'} +
+
+ {song?.artist ?? ''} +
+
+ {isHostPick + ? t('orbit.queueAddedByHost') + : t('orbit.guestSubmitter', { user: q.addedBy })} +
+
+
+ ); + })} + + {truncatedBy > 0 && ( +
+ {t('orbit.guestUpNextMore', { count: truncatedBy })} +
+ )} +
+ +
{t('orbit.guestFooter')}
+
+ ); +} diff --git a/src/components/OrbitHelpModal.tsx b/src/components/OrbitHelpModal.tsx new file mode 100644 index 00000000..5a84f216 --- /dev/null +++ b/src/components/OrbitHelpModal.tsx @@ -0,0 +1,131 @@ +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { + X, Sparkles, Users, Share2, LogIn, MousePointerClick, + ListMusic, Inbox, Sliders, LogOut, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useHelpModalStore } from '../store/helpModalStore'; +import SettingsSubSection from './SettingsSubSection'; + +/** + * Orbit help modal. Rendered once at the app root; triggered from the + * launch popover ("How does this work?") and the in-session bar's help + * button. 9 accordion sections built on SettingsSubSection; all default + * closed so the modal opens compact. Does not touch playback. + */ +export default function OrbitHelpModal() { + const { t } = useTranslation(); + const { isOpen, close } = useHelpModalStore(); + const bodyRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + // Focus the first accordion summary so arrow keys work immediately. + // Uses a short setTimeout because the browser re-focuses the clicked + // trigger button after the click handler returns — our focus call has + // to happen *after* that, otherwise the browser silently overrides it + // and the user only gets keyboard nav after pressing Tab first. + const id = window.setTimeout(() => { + const first = bodyRef.current?.querySelector('summary'); + first?.focus(); + }, 60); + return () => window.clearTimeout(id); + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { close(); return; } + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; + const summaries = Array.from( + bodyRef.current?.querySelectorAll('summary') ?? [], + ); + if (summaries.length === 0) return; + const current = document.activeElement as HTMLElement | null; + const idx = summaries.indexOf(current as HTMLElement); + e.preventDefault(); + const next = e.key === 'ArrowDown' + ? summaries[(idx + 1 + summaries.length) % summaries.length] + : summaries[(idx - 1 + summaries.length) % summaries.length]; + next?.focus(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [isOpen, close]); + + if (!isOpen) return null; + + const hostOnlyLabel = t('orbit.helpHostOnly'); + + return createPortal( +
{ if (e.target === e.currentTarget) close(); }} + role="dialog" + aria-modal="true" + aria-labelledby="orbit-help-title" + style={{ alignItems: 'center', paddingTop: 0 }} + > +
+ + +

+ {t('orbit.helpTitle')} +

+

{t('orbit.helpIntro')}

+ +
+ }> +

{t('orbit.helpSec1Body')}

+
+ + }> +

{t('orbit.helpSec2Body')}

+
+ {t('orbit.helpSec2WarnHead')} + {t('orbit.helpSec2WarnBody')} +
+
+ + }> +

{t('orbit.helpSec3Body')}

+
+ + }> +

{t('orbit.helpSec4Body')}

+
+ + }> +

{t('orbit.helpSec5Body')}

+
+ + }> +

{t('orbit.helpSec6Body')}

+
+ + } + > +

{t('orbit.helpSec7Body')}

+
+ + } + > +

{t('orbit.helpSec8Body')}

+
+ + }> +

{t('orbit.helpSec9Body')}

+
+
+
+
, + document.body, + ); +} diff --git a/src/components/OrbitJoinModal.tsx b/src/components/OrbitJoinModal.tsx new file mode 100644 index 00000000..c20a777f --- /dev/null +++ b/src/components/OrbitJoinModal.tsx @@ -0,0 +1,158 @@ +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { X, LogIn, ClipboardPaste } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../store/authStore'; +import { + parseOrbitShareLink, + findSessionPlaylistId, + readOrbitState, + joinOrbitSession, +} from '../utils/orbit'; +import { switchActiveServer } from '../utils/switchActiveServer'; +import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore'; +import { showToast } from '../utils/toast'; + +interface Props { + onClose: () => void; +} + +/** + * Orbit — manual join modal. Alternative to the Ctrl+V paste shortcut for + * users who don't want to (or can't) paste the invite link into the app + * directly. Reuses the same parse + preflight pipeline the clipboard + * handler uses, so error surfaces stay consistent. + */ +export default function OrbitJoinModal({ onClose }: Props) { + const { t } = useTranslation(); + const [link, setLink] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const onPaste = async () => { + try { + const clip = await navigator.clipboard.readText(); + if (clip) setLink(clip); + } catch { /* silent — clipboard perms vary */ } + }; + + const onJoin = async () => { + setError(null); + const text = link.trim(); + if (!text) { setError(t('orbit.joinErrEmpty')); return; } + const parsed = parseOrbitShareLink(text); + if (!parsed) { setError(t('orbit.joinErrInvalid')); return; } + + const active = useAuthStore.getState().getActiveServer(); + const activeUrl = (active?.url ?? '').replace(/\/+$/, ''); + const wantUrl = parsed.serverBase.replace(/\/+$/, ''); + + setBusy(true); + try { + // Auto-switch to the link's server if the user has an account for it. + // Multiple candidates → picker modal. switch tears down any lingering + // orbit session. + if (activeUrl !== wantUrl) { + const candidates = useAuthStore.getState().servers + .filter(s => s.url.replace(/\/+$/, '') === wantUrl); + if (candidates.length === 0) { + setError(t('orbit.toastNoAccountForServer', { url: wantUrl })); + return; + } + const target = candidates.length === 1 + ? candidates[0] + : await useOrbitAccountPickerStore.getState().request(candidates); + if (!target) { setBusy(false); return; } + const switched = await switchActiveServer(target); + if (!switched) { + setError(t('orbit.toastSwitchFailed', { url: wantUrl })); + return; + } + } + + const playlistId = await findSessionPlaylistId(parsed.sid); + if (!playlistId) { setError(t('orbit.joinErrNotFound')); return; } + const state = await readOrbitState(playlistId); + if (!state) { setError(t('orbit.joinErrNotFound')); return; } + if (state.ended) { setError(t('orbit.joinErrEnded')); return; } + await joinOrbitSession(parsed.sid); + showToast(t('orbit.toastJoined'), 2200, 'info'); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : t('orbit.toastJoinFail')); + } finally { + setBusy(false); + } + }; + + return createPortal( +
{ if (e.target === e.currentTarget) onClose(); }} + role="dialog" + aria-modal="true" + aria-labelledby="orbit-join-title" + > +
+ + +
+
+ +
+

+ {t('orbit.joinModalTitle')} +

+

{t('orbit.joinModalSub')}

+
+ +
+ +
+ { setLink(e.target.value); setError(null); }} + onKeyDown={e => { if (e.key === 'Enter' && !busy) void onJoin(); }} + placeholder={t('orbit.joinModalLinkPlaceholder')} + className="orbit-start-modal__input" + /> + +
+
{t('orbit.joinModalLinkHelper')}
+
+ + {error &&
{error}
} + +
+ + +
+
+
, + document.body, + ); +} diff --git a/src/components/OrbitParticipantsPopover.tsx b/src/components/OrbitParticipantsPopover.tsx new file mode 100644 index 00000000..db8404a3 --- /dev/null +++ b/src/components/OrbitParticipantsPopover.tsx @@ -0,0 +1,157 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Crown, User, UserMinus, ShieldOff, Mic, MicOff } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { kickOrbitParticipant, removeOrbitParticipant, setOrbitSuggestionBlocked } from '../utils/orbit'; +import ConfirmModal from './ConfirmModal'; + +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 { t } = useTranslation(); + const state = useOrbitStore(s => s.state); + const role = useOrbitStore(s => s.role); + const popRef = useRef(null); + const [confirm, setConfirm] = useState<{ user: string; mode: 'remove' | 'ban' } | null>(null); + const nowMs = Date.now(); + + // Close on outside click / Escape — unless a confirm dialog is open + // (otherwise outside-clicking the modal would dismiss the popover too, + // and re-opening would lose the in-flight confirm context). + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (confirm) return; + 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 (confirm) return; + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [anchorRef, onClose, confirm]); + + if (!state) return null; + + const anchor = anchorRef.current?.getBoundingClientRect(); + const style: React.CSSProperties = anchor + ? { + position: 'fixed', + top: anchor.bottom + 12, + left: Math.max(8, anchor.left - 100), + zIndex: 9999, + } + : { display: 'none' }; + + const onConfirm = async () => { + if (!confirm) return; + const { user, mode } = confirm; + setConfirm(null); + if (mode === 'remove') await removeOrbitParticipant(user); + else await kickOrbitParticipant(user); + }; + + return createPortal( + <> +
+
+ {t('orbit.participantsCountLabel', { count: state.participants.length + 1 })} +
+ +
+ + {state.host} + {t('orbit.participantsHost')} +
+ + {state.participants.length === 0 && ( +
{t('orbit.participantsEmpty')}
+ )} + + {state.participants.map(p => { + const isMuted = state.suggestionBlocked?.includes(p.user) ?? false; + return ( +
+ + {p.user} + {joinedFor(p.joinedAt, nowMs)} + {role === 'host' && ( +
+ + + +
+ )} +
+ ); + })} +
+ { void onConfirm(); }} + onCancel={() => setConfirm(null)} + /> + , + document.body, + ); +} diff --git a/src/components/OrbitQueueHead.tsx b/src/components/OrbitQueueHead.tsx new file mode 100644 index 00000000..da8f6a85 --- /dev/null +++ b/src/components/OrbitQueueHead.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import { Users, Wifi, WifiOff } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import type { OrbitState } from '../api/orbit'; + +interface Props { + state: OrbitState; +} + +/** Host's state hasn't updated for this long → guest treats them as offline. */ +const HOST_AWAY_THRESHOLD_MS = 15_000; + +/** + * Shared Orbit head strip rendered at the top of the queue for both host + * and guest. Shows the session name and a comma-separated list of every + * participant (host first, then guests in join order). + * + * Guest view additionally surfaces host-presence: when the host's tick + * hasn't been seen for 15 s we render a subtle "host offline" badge so + * the guest knows the stalled playback isn't a local problem. + */ +export default function OrbitQueueHead({ state }: Props) { + const { t } = useTranslation(); + const role = useOrbitStore(s => s.role); + const [nowMs, setNowMs] = useState(() => Date.now()); + + // Guest-only clock tick — React wouldn't re-render a stale state blob + // on its own, and the presence threshold is time-based. + useEffect(() => { + if (role !== 'guest') return; + const id = window.setInterval(() => setNowMs(Date.now()), 2000); + return () => window.clearInterval(id); + }, [role]); + + const names = [state.host, ...state.participants.map(p => p.user)]; + const showPresence = role === 'guest' && state.positionAt > 0; + const hostAway = showPresence && (nowMs - state.positionAt) > HOST_AWAY_THRESHOLD_MS; + + return ( +
+
+

{state.name}

+ {showPresence && ( + + {hostAway ? : } + {t(hostAway ? 'orbit.hostAway' : 'orbit.hostOnline')} + + )} +
+
+ + {names.join(', ')} +
+
+ ); +} diff --git a/src/components/OrbitSessionBar.tsx b/src/components/OrbitSessionBar.tsx new file mode 100644 index 00000000..35dd8d24 --- /dev/null +++ b/src/components/OrbitSessionBar.tsx @@ -0,0 +1,269 @@ +import { useEffect, useRef, useState } from 'react'; +import { X, RefreshCw, Shuffle, Settings2, Share2, HelpCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { useHelpModalStore } from '../store/helpModalStore'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { getSong } from '../api/subsonic'; +import { + endOrbitSession, + leaveOrbitSession, + computeOrbitDriftMs, + effectiveShuffleIntervalMs, +} from '../utils/orbit'; +import { estimateLivePosition } from '../api/orbit'; +import OrbitParticipantsPopover from './OrbitParticipantsPopover'; +import OrbitExitModal from './OrbitExitModal'; +import OrbitSettingsPopover from './OrbitSettingsPopover'; +import OrbitSharePopover from './OrbitSharePopover'; +import ConfirmModal from './ConfirmModal'; + +/** + * Orbit — top-strip session indicator. + * + * Visible whenever the local store reports an active (or just-ended) + * session. Shows session name, host, participant count, shuffle countdown, + * and role-appropriate action buttons (catch-up for guests, exit for + * everyone). + * + * Deliberately low-chrome: sits above the rest of the app without + * reshaping the layout. + */ + +const CATCH_UP_DRIFT_THRESHOLD_MS = 3_000; + +function formatCountdown(ms: number): string { + const clamped = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(clamped / 60); + const s = clamped % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +} + +export default function OrbitSessionBar() { + const { t } = useTranslation(); + 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 [settingsOpen, setSettingsOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + const [confirmLeave, setConfirmLeave] = useState(false); + const peopleBtnRef = useRef(null); + const settingsBtnRef = useRef(null); + const shareBtnRef = 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 + // countdown. + useEffect(() => { + if (!state || phase !== 'active') return; + const id = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(id); + }, [state, phase]); + + // Bar is visible while active, ended (pre-ack), or explicitly kicked / soft-removed. + const shouldShowBar = !!state && ( + phase === 'active' + || phase === 'ended' + || (phase === 'error' && (errorMessage === 'kicked' || errorMessage === 'removed')) + ); + if (!shouldShowBar || !state) return ( + + ); + + const untilShuffle = Math.max(0, (state.lastShuffle + effectiveShuffleIntervalMs(state)) - 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 performExit = async () => { + try { + if (role === 'host') await endOrbitSession(); + else if (role === 'guest') await leaveOrbitSession(); + else useOrbitStore.getState().reset(); + } catch { + useOrbitStore.getState().reset(); + } + }; + + const onExit = () => { + // Active-session exits get a confirm — guests don't want to drop out on + // a fat-finger, and the host's X ends the session for everyone, so + // accidentally clicking it is even worse. Post-end/kicked dismissals + // skip the confirm (the session is already over there). + if (phase === 'active' && (role === 'guest' || role === 'host')) { + setConfirmLeave(true); + return; + } + void performExit(); + }; + + 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); + const hostPlaying = state.isPlaying; + try { + const song = await getSong(trackId); + if (!song) return; + const track = songToTrack(song); + const player = usePlayerStore.getState(); + const fraction = targetSec / Math.max(1, track.duration); + if (player.currentTrack?.id === trackId) { + player.seek(fraction); + if (hostPlaying && !player.isPlaying) player.resume(); + else if (!hostPlaying && player.isPlaying) player.pause(); + } else { + // Different track: play + seek on next tick once engine is ready. + player.playTrack(track, [track]); + window.setTimeout(() => { + const p = usePlayerStore.getState(); + if (p.currentTrack?.id !== trackId) return; + p.seek(fraction); + if (!hostPlaying && p.isPlaying) p.pause(); + }, 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 ( +
+
+
+ +
+ + + {t('orbit.shuffleLabel')} + {formatCountdown(untilShuffle)} + +
+ +
+ {role === 'host' && ( + + )} + {role === 'host' && ( + + )} + {showCatchUp && ( + + )} + + +
+ + {peopleOpen && ( + setPeopleOpen(false)} + /> + )} + {settingsOpen && ( + setSettingsOpen(false)} + /> + )} + {shareOpen && ( + setShareOpen(false)} + /> + )} + + { setConfirmLeave(false); void performExit(); }} + onCancel={() => setConfirmLeave(false)} + /> +
+ ); +} diff --git a/src/components/OrbitSettingsPopover.tsx b/src/components/OrbitSettingsPopover.tsx new file mode 100644 index 00000000..2070052f --- /dev/null +++ b/src/components/OrbitSettingsPopover.tsx @@ -0,0 +1,131 @@ +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { Shuffle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { updateOrbitSettings, triggerOrbitShuffleNow } from '../utils/orbit'; +import { ORBIT_DEFAULT_SETTINGS, ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN, type OrbitShuffleIntervalMin } from '../api/orbit'; +import { showToast } from '../utils/toast'; + +interface Props { + anchorRef: React.RefObject; + onClose: () => void; +} + +/** + * Host-only popover anchored below the settings button in the Orbit bar. + * Two toggles; writes are pushed immediately to Navidrome via + * `updateOrbitSettings`. + */ +export default function OrbitSettingsPopover({ anchorRef, onClose }: Props) { + const { t } = useTranslation(); + const settings = useOrbitStore(s => s.state?.settings) ?? ORBIT_DEFAULT_SETTINGS; + const popRef = useRef(null); + + useEffect(() => { + const onDown = (e: MouseEvent) => { + const target = e.target as Node | null; + if (popRef.current?.contains(target)) return; + if (anchorRef.current?.contains(target)) 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]); + + const anchor = anchorRef.current?.getBoundingClientRect(); + const style: React.CSSProperties = anchor + ? { + position: 'fixed', + top: anchor.bottom + 12, + right: Math.max(8, window.innerWidth - anchor.right), + zIndex: 9999, + } + : { display: 'none' }; + + return createPortal( +
+
{t('orbit.settingsTitle')}
+ + + + + +
+
+
{t('orbit.settingShuffleInterval')}
+
{t('orbit.settingShuffleIntervalHint')}
+
+
+ {ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN.map(min => { + const active = (settings.shuffleIntervalMin ?? 15) === min; + return ( + + ); + })} +
+
+ + +
, + document.body, + ); +} diff --git a/src/components/OrbitSharePopover.tsx b/src/components/OrbitSharePopover.tsx new file mode 100644 index 00000000..e0b88c9f --- /dev/null +++ b/src/components/OrbitSharePopover.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Copy, Check } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { useAuthStore } from '../store/authStore'; +import { buildOrbitShareLink } from '../utils/orbit'; + +interface Props { + anchorRef: React.RefObject; + onClose: () => void; +} + +/** + * Host-only popover anchored below the share button in the Orbit bar. + * Surfaces the session invite link with a copy affordance. Lives on its + * own so the participants popover can stay focused on participants. + */ +export default function OrbitSharePopover({ anchorRef, onClose }: Props) { + const { t } = useTranslation(); + const sessionId = useOrbitStore(s => s.sessionId); + const popRef = useRef(null); + const [copied, setCopied] = useState(false); + + const shareLink = sessionId + ? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId) + : null; + + useEffect(() => { + const onDown = (e: MouseEvent) => { + const target = e.target as Node | null; + if (popRef.current?.contains(target)) return; + if (anchorRef.current?.contains(target)) 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]); + + const onCopy = async () => { + if (!shareLink) return; + try { + await navigator.clipboard.writeText(shareLink); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { /* silent */ } + }; + + if (!shareLink) return null; + + const anchor = anchorRef.current?.getBoundingClientRect(); + const style: React.CSSProperties = anchor + ? { + position: 'fixed', + top: anchor.bottom + 12, + right: Math.max(8, window.innerWidth - anchor.right), + zIndex: 9999, + } + : { display: 'none' }; + + return createPortal( +
+
{t('orbit.participantsInviteLabel')}
+
+ {shareLink} + +
+
, + document.body, + ); +} diff --git a/src/components/OrbitStartModal.tsx b/src/components/OrbitStartModal.tsx new file mode 100644 index 00000000..95c13b9a --- /dev/null +++ b/src/components/OrbitStartModal.tsx @@ -0,0 +1,233 @@ +import { useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { + X, Check, Copy, Orbit as OrbitIcon, + Dices, AlertTriangle, Globe2, +} from 'lucide-react'; +import { + startOrbitSession, + buildOrbitShareLink, + generateSessionId, +} from '../utils/orbit'; +import { randomOrbitSessionName } from '../utils/orbitNames'; +import { useAuthStore } from '../store/authStore'; +import { usePlayerStore } from '../store/playerStore'; +import { isLanUrl } from '../hooks/useConnectionStatus'; +import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit'; + +interface Props { onClose: () => void; } + +/** + * Orbit — start-session modal. + * + * One-screen flow: a share-link is shown immediately (built from a + * pre-generated session id + a slug derived from the live name). The host + * can copy it any time; pressing "Start" creates the session under that + * same id and auto-copies the link if it hasn't been copied yet. + */ +export default function OrbitStartModal({ onClose }: Props) { + const { t } = useTranslation(); + const [sid] = useState(() => generateSessionId()); + const [name, setName] = useState(() => randomOrbitSessionName()); + const [maxUsers, setMaxUsers] = useState(ORBIT_DEFAULT_MAX_USERS); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [hasCopied, setHasCopied] = useState(false); + const [clearQueue, setClearQueue] = useState(false); + + const server = useAuthStore.getState().getActiveServer(); + const serverBase = server?.url ?? ''; + const serverName = server?.name ?? server?.url ?? t('orbit.fallbackServer'); + const onLan = isLanUrl(serverBase); + + const shareLink = useMemo( + () => buildOrbitShareLink(serverBase, sid), + [serverBase, sid], + ); + + const writeLinkToClipboard = async (): Promise => { + try { + await navigator.clipboard.writeText(shareLink); + return true; + } catch { + return false; + } + }; + + const onCopy = async () => { + const ok = await writeLinkToClipboard(); + if (ok) { + setCopied(true); + setHasCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } + }; + + const onStart = async () => { + setError(null); + const trimmed = name.trim(); + if (!trimmed) { setError(t('orbit.errNameRequired')); return; } + + if (!hasCopied) { + const ok = await writeLinkToClipboard(); + if (ok) setHasCopied(true); + } + + setBusy(true); + try { + if (clearQueue) usePlayerStore.getState().clearQueue(); + await startOrbitSession({ name: trimmed, maxUsers, sid }); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : t('orbit.errStartFailed')); + } finally { + setBusy(false); + } + }; + + const heroSubParts = t('orbit.heroSub', { server: serverName }).split(String(serverName)); + + return createPortal( +
{ if (e.target === e.currentTarget) onClose(); }} + role="dialog" + aria-modal="true" + aria-labelledby="orbit-start-title" + > +
+ + +
+
+ +
+

+ {t('orbit.heroTitlePrefix')}{' '} + {t('orbit.heroTitleBrand')} +

+

+ {heroSubParts[0]} + {serverName} + {heroSubParts[1] ?? ''} +

+
+ +
+ {onLan ? : } + {onLan ? t('orbit.tipLan') : t('orbit.tipRemote')} +
+ +
+ +
+ { setName(e.target.value); setHasCopied(false); }} + onKeyDown={e => { + if (e.key !== 'Enter') return; + if (busy || !name.trim()) return; + e.preventDefault(); + void onStart(); + }} + placeholder={t('orbit.namePlaceholder')} + maxLength={40} + className="orbit-start-modal__input" + /> + +
+
{t('orbit.helperName')}
+
+ +
+ + setMaxUsers(Number(e.target.value))} + className="orbit-start-modal__range" + /> +
{t('orbit.helperMax')}
+
+ +
+ +
+ +
+ +
+ {shareLink} + +
+
{t('orbit.helperLink')}
+
+ + {error &&
{error}
} + +
+ + +
+
+
, + document.body, + ); +} diff --git a/src/components/OrbitStartTrigger.tsx b/src/components/OrbitStartTrigger.tsx new file mode 100644 index 00000000..061156e9 --- /dev/null +++ b/src/components/OrbitStartTrigger.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Orbit as OrbitIcon, Plus, LogIn, HelpCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { useAuthStore } from '../store/authStore'; +import { useHelpModalStore } from '../store/helpModalStore'; +import OrbitStartModal from './OrbitStartModal'; +import OrbitJoinModal from './OrbitJoinModal'; +import OrbitWordmark from './OrbitWordmark'; + +/** + * Topbar trigger — opens a small launch popover offering three choices: + * create a new session, join an existing one via invite link, or open the + * Orbit help section. Hidden while a session is already active so we + * don't offer entry points while the user's session bar is already live. + */ +export default function OrbitStartTrigger() { + const { t } = useTranslation(); + const role = useOrbitStore(s => s.role); + const visible = useAuthStore(s => s.showOrbitTrigger); + + const [popoverOpen, setPopoverOpen] = useState(false); + const [startOpen, setStartOpen] = useState(false); + const [joinOpen, setJoinOpen] = useState(false); + const btnRef = useRef(null); + const popRef = useRef(null); + + // Close popover on outside click / Escape. + useEffect(() => { + if (!popoverOpen) return; + const onDown = (e: MouseEvent) => { + const target = e.target as Node | null; + if (popRef.current?.contains(target)) return; + if (btnRef.current?.contains(target)) return; + setPopoverOpen(false); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPopoverOpen(false); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [popoverOpen]); + + if (role !== null) return null; + if (!visible) return null; + + const anchor = btnRef.current?.getBoundingClientRect(); + const popoverStyle: React.CSSProperties = anchor + ? { + position: 'fixed', + top: anchor.bottom + 8, + left: anchor.left, + zIndex: 9999, + } + : { display: 'none' }; + + const pickCreate = () => { setPopoverOpen(false); setStartOpen(true); }; + const pickJoin = () => { setPopoverOpen(false); setJoinOpen(true); }; + const pickHelp = () => { setPopoverOpen(false); useHelpModalStore.getState().open(); }; + + return ( + <> + + + {popoverOpen && createPortal( +
+ + + +
, + document.body, + )} + + {startOpen && setStartOpen(false)} />} + {joinOpen && setJoinOpen(false)} />} + + ); +} diff --git a/src/components/OrbitWordmark.tsx b/src/components/OrbitWordmark.tsx new file mode 100644 index 00000000..07d9d40f --- /dev/null +++ b/src/components/OrbitWordmark.tsx @@ -0,0 +1,27 @@ +interface Props { + height?: number; + className?: string; +} + +export default function OrbitWordmark({ height = 14, className }: Props) { + const aspect = 196.08815 / 57.845078; + return ( + + ); +} diff --git a/src/components/PasteClipboardHandler.tsx b/src/components/PasteClipboardHandler.tsx index 389866cd..7bd4e7c3 100644 --- a/src/components/PasteClipboardHandler.tsx +++ b/src/components/PasteClipboardHandler.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; @@ -6,6 +6,25 @@ import { decodeSharePayloadFromText } from '../utils/shareLink'; import { decodeServerMagicStringFromText } from '../utils/serverMagicString'; import { applySharePastePayload } from '../utils/applySharePaste'; import { showToast } from '../utils/toast'; +import { + parseOrbitShareLink, + joinOrbitSession, + findSessionPlaylistId, + readOrbitState, + OrbitJoinError, +} from '../utils/orbit'; +import { switchActiveServer } from '../utils/switchActiveServer'; +import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore'; +import ConfirmModal from './ConfirmModal'; + +const ORBIT_JOIN_ERROR_KEYS: Record = { + 'not-found': 'orbit.joinErrNotFound', + 'ended': 'orbit.joinErrEnded', + 'full': 'orbit.joinErrFull', + 'kicked': 'orbit.joinErrKicked', + 'no-user': 'orbit.joinErrNoUser', + 'server-error': 'orbit.joinErrServerError', +}; /** * Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`) @@ -16,6 +35,33 @@ export default function PasteClipboardHandler() { const { t } = useTranslation(); const isLoggedIn = useAuthStore(s => s.isLoggedIn); const busy = useRef(false); + const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null); + const [orbitInvalid, setOrbitInvalid] = useState(false); + + // `not-found` and `ended` collapse into a single "link no longer valid" + // dialog — from the guest's POV both mean the same thing: the invite + // doesn't lead anywhere any more. Other reasons stay as toasts because + // they're actionable (full → wait, kicked → talk to host, etc.). + const handleJoinError = (reason: string | null, fallback?: string) => { + if (reason === 'not-found' || reason === 'ended') { + setOrbitInvalid(true); + return; + } + const i18nKey = reason ? ORBIT_JOIN_ERROR_KEYS[reason] : null; + showToast(i18nKey ? t(i18nKey) : (fallback ?? t('orbit.toastJoinFail')), 4000, 'error'); + }; + + const runOrbitJoin = (sid: string) => { + if (busy.current) return; + busy.current = true; + joinOrbitSession(sid) + .then(() => showToast(t('orbit.toastJoined'), 2500, 'info')) + .catch(err => { + if (err instanceof OrbitJoinError) handleJoinError(err.reason, err.message); + else handleJoinError(null); + }) + .finally(() => { busy.current = false; }); + }; useEffect(() => { const onPaste = (e: ClipboardEvent) => { @@ -30,6 +76,59 @@ export default function PasteClipboardHandler() { return; } const text = e.clipboardData?.getData('text/plain') ?? ''; + + // Orbit share link — handled before library shares. + const orbit = parseOrbitShareLink(text.trim()); + if (orbit) { + e.preventDefault(); + e.stopPropagation(); + if (!isLoggedIn) { showToast(t('orbit.toastLoginFirst'), 4000, 'info'); return; } + if (busy.current) return; + busy.current = true; + + (async () => { + const active = useAuthStore.getState().getActiveServer(); + const activeUrl = (active?.url ?? '').replace(/\/+$/, ''); + const wantUrl = orbit.serverBase.replace(/\/+$/, ''); + + // Auto-switch to the link's target server if the user has an + // account registered for it. No account → clear error. Multiple + // accounts for the same URL → picker lets the user choose. The + // switch itself tears down any lingering orbit session (see + // switchActiveServer) so the join below starts clean. + if (activeUrl !== wantUrl) { + const candidates = useAuthStore.getState().servers + .filter(s => s.url.replace(/\/+$/, '') === wantUrl); + if (candidates.length === 0) { + showToast(t('orbit.toastNoAccountForServer', { url: wantUrl }), 5000, 'warning'); + return; + } + const target = candidates.length === 1 + ? candidates[0] + : await useOrbitAccountPickerStore.getState().request(candidates); + if (!target) return; // cancelled + const switched = await switchActiveServer(target); + if (!switched) { + showToast(t('orbit.toastSwitchFailed', { url: wantUrl }), 5000, 'error'); + return; + } + } + + // Preview the session state so the confirm dialog can show the + // host and session name. Failures surface the same error toasts + // the join would, without ever showing the confirm. + const playlistId = await findSessionPlaylistId(orbit.sid); + if (!playlistId) { handleJoinError('not-found'); return; } + const state = await readOrbitState(playlistId); + if (!state) { handleJoinError('not-found'); return; } + if (state.ended) { handleJoinError('ended'); return; } + setOrbitConfirm({ sid: orbit.sid, host: state.host, name: state.name }); + })() + .catch(() => handleJoinError(null)) + .finally(() => { busy.current = false; }); + return; + } + const share = decodeSharePayloadFromText(text); if (share) { if (!isLoggedIn) { @@ -74,5 +173,31 @@ export default function PasteClipboardHandler() { return () => document.removeEventListener('paste', onPaste, true); }, [navigate, t, isLoggedIn]); - return null; + return ( + <> + { + const sid = orbitConfirm?.sid; + setOrbitConfirm(null); + if (sid) runOrbitJoin(sid); + }} + onCancel={() => setOrbitConfirm(null)} + /> + setOrbitInvalid(false)} + /> + + ); } diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 6cd523e2..97140f32 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -1,5 +1,9 @@ import React, { useState, useRef, useMemo, useEffect } from 'react'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; +import { useOrbitStore } from '../store/orbitStore'; +import OrbitGuestQueue from './OrbitGuestQueue'; +import OrbitQueueHead from './OrbitQueueHead'; +import HostApprovalQueue from './HostApprovalQueue'; import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, MoveRight, Radio, HardDrive, ChevronDown, Info, Share2 } from 'lucide-react'; import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { usePlaylistStore } from '../store/playlistStore'; @@ -231,8 +235,44 @@ function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTim } export default function QueuePanel() { + const orbitRole = useOrbitStore(s => s.role); + if (orbitRole === 'guest') { + return ( + + ); + } + return ; +} + +function QueuePanelHostOrSolo() { const { t } = useTranslation(); const navigate = useNavigate(); + const orbitRole = useOrbitStore(s => s.role); + const orbitState = useOrbitStore(s => s.state); + /** trackId → addedBy (host username or guest username) — only populated while + * hosting an Orbit session, so the queue rows can surface attribution. */ + const orbitAddedByByTrack = useMemo(() => { + const map = new Map(); + if (orbitRole !== 'host' || !orbitState) return map; + if (orbitState.currentTrack) { + map.set(orbitState.currentTrack.trackId, orbitState.currentTrack.addedBy); + } + for (const q of orbitState.queue) map.set(q.trackId, q.addedBy); + return map; + }, [orbitRole, orbitState]); + const orbitHostUsername = orbitState?.host ?? ''; + /** Attribution label for a queue row / current track while hosting. Null when + * not in a hosted session. Bulk-adds (album / playlist enqueue) bypass + * `hostEnqueueToOrbit` and therefore never land in `state.queue`, so we + * default those to "Added by you" rather than showing nothing. */ + const orbitAttributionLabel = (trackId: string): string | null => { + if (orbitRole !== 'host' || !orbitState) return null; + const addedBy = orbitAddedByByTrack.get(trackId); + if (!addedBy || addedBy === orbitHostUsername) return t('orbit.queueAddedByYou'); + return t('orbit.queueAddedByUser', { user: addedBy }); + }; const queue = usePlayerStore(s => s.queue); const queueIndex = usePlayerStore(s => s.queueIndex); const currentTrack = usePlayerStore(s => s.currentTrack); @@ -451,6 +491,12 @@ export default function QueuePanel() { borderLeftWidth: isQueueVisible ? 1 : 0, }} > + {orbitRole === 'host' && orbitState && ( + <> + + + + )} {currentTrack.year}
)} + {(() => { + const label = orbitAttributionLabel(currentTrack.id); + return label ?
{label}
: null; + })()} {renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
@@ -724,6 +774,10 @@ export default function QueuePanel() { {track.title}
{track.artist}
+ {(() => { + const label = orbitAttributionLabel(track.id); + return label ?
{label}
: null; + })()}
{formatTime(track.duration)} diff --git a/src/components/SongCard.tsx b/src/components/SongCard.tsx index 73573ea2..bfce2e4b 100644 --- a/src/components/SongCard.tsx +++ b/src/components/SongCard.tsx @@ -7,6 +7,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore'; import CachedImage from './CachedImage'; import { enqueueAndPlay } from '../utils/playSong'; import { useDragDrop } from '../contexts/DragDropContext'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; interface SongCardProps { song: SubsonicSong; @@ -19,8 +20,19 @@ function SongCard({ song }: SongCardProps) { const enqueue = usePlayerStore(s => s.enqueue); const coverUrl = song.coverArt ? buildCoverArtUrl(song.coverArt, 200) : ''; const psyDrag = useDragDrop(); + const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior(); - const handleClick = () => enqueueAndPlay(song); + const handlePlay = () => { + if (orbitActive) { addTrackToOrbit(song.id); return; } + enqueueAndPlay(song); + }; + + const handleEnqueue = () => { + if (orbitActive) { addTrackToOrbit(song.id); return; } + enqueue([songToTrack(song)]); + }; + + const handleClick = handlePlay; const handleArtistClick = (e: React.MouseEvent) => { if (!song.artistId) return; @@ -80,7 +92,7 @@ function SongCard({ song }: SongCardProps) {