refactor(orbit): co-locate orbit feature into features/orbit

This commit is contained in:
Psychotoxical
2026-06-30 01:02:58 +02:00
parent d6dbb615fd
commit 321d5ff6ab
93 changed files with 295 additions and 248 deletions
+248
View File
@@ -0,0 +1,248 @@
/**
* 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];
/**
* Host's playback-transition preferences, mirrored into the session so guests
* blend tracks the same way the host does (otherwise each client uses its own
* local transition settings, re-introducing the drift the Catch-Up button
* exists to fix). Optional on the wire — a session hosted by a build that
* predates transition sync simply omits it, and guests keep their own.
*/
export interface OrbitTransitionSettings {
crossfadeEnabled: boolean;
crossfadeSecs: number;
crossfadeTrimSilence: boolean;
autodjSmoothSkip: boolean;
gaplessEnabled: boolean;
/** Optional — absent on sessions hosted by builds before overlap-cap sync. */
autodjOverlapCapMode?: 'auto' | 'limit';
autodjOverlapCapSec?: 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;
/**
* Host's track-transition prefs (crossfade / gapless / AutoDJ), refreshed
* every host tick from the host's own playback settings. Guests adopt these
* for the session and restore their own on leave. Optional: absent on
* pre-transition-sync sessions.
*/
transitions?: OrbitTransitionSettings;
}
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<OrbitState>;
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);
}
@@ -0,0 +1,103 @@
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 '@/features/orbit/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;
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
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(
<div
className="modal-overlay"
onClick={e => { if (e.target === e.currentTarget) cancel(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-account-picker-title"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div className="modal-content orbit-account-picker">
<button type="button" className="modal-close" onClick={cancel} aria-label={t('orbit.btnCancel')}>
<X size={18} />
</button>
<h3 id="orbit-account-picker-title" className="orbit-account-picker__title">
{t('orbit.accountPickerTitle')}
</h3>
<p className="orbit-account-picker__sub">
{t('orbit.accountPickerSub', { url: accounts[0]?.url ?? '' })}
</p>
<ul className="orbit-account-picker__list" role="listbox">
{accounts.map((a, i) => (
<li key={a.id} role="option" aria-selected={i === selected}>
<button
ref={el => { itemRefs.current[i] = el; }}
type="button"
className={`orbit-account-picker__item${i === selected ? ' is-active' : ''}`}
onClick={() => pick(a)}
onMouseEnter={() => setSelected(i)}
>
<User size={14} />
<span className="orbit-account-picker__user">{a.username}</span>
{a.name && <span className="orbit-account-picker__name">· {a.name}</span>}
</button>
</li>
))}
</ul>
<div className="orbit-account-picker__actions">
<button type="button" className="btn btn-ghost" onClick={cancel}>
{t('orbit.btnCancel')}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,187 @@
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { Copy, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { usePlayerStore } from '@/store/playerStore';
import { showToast } from '@/utils/ui/toast';
import { computeOrbitDriftMs } from '@/features/orbit/utils/orbit';
import {
clearOrbitEvents,
formatOrbitEvents,
getOrbitEvents,
subscribeOrbitEvents,
} from '@/features/orbit/utils/orbitDiag';
interface Props {
anchorRef: React.RefObject<HTMLElement | null>;
onClose: () => void;
}
/**
* Live diagnostic popover for the Orbit bar. Renders a mini-summary of the
* host vs guest state plus a scrolling event log captured by `orbitDiag`.
*
* The point is "Discord user can paste a buffer" — so the Copy button is
* the primary action and the textarea is read-only. Clear lets a user wipe
* the buffer before reproducing a specific symptom.
*/
export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
const { t } = useTranslation();
const popRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
// Live event buffer subscription via useSyncExternalStore — no extra
// re-render cascade, no flicker.
const events = useSyncExternalStore(subscribeOrbitEvents, getOrbitEvents, getOrbitEvents);
const formatted = formatOrbitEvents(events);
// Tick the mini-display once a second so drift / position read fresh.
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(id);
}, []);
// Auto-scroll the textarea to the bottom whenever a new event lands so
// the most recent line is always visible without manual scrolling.
useEffect(() => {
if (taRef.current) taRef.current.scrollTop = taRef.current.scrollHeight;
}, [formatted]);
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]);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
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' };
// ── Live mini-display data ────────────────────────────────────────────
const role = useOrbitStore(s => s.role);
const state = useOrbitStore(s => s.state);
const player = usePlayerStore.getState();
const localPosMs = Math.round((player.currentTime ?? 0) * 1000);
const sameTrack = role === 'guest'
&& state?.currentTrack
&& player.currentTrack?.id === state.currentTrack.trackId;
const driftMs = sameTrack && state ? computeOrbitDriftMs(state, localPosMs, nowMs) : null;
const hostStateAgeMs = state ? Math.max(0, nowMs - state.positionAt) : null;
const hostPosSec = state ? Math.round(((state.positionMs ?? 0) + (state.isPlaying ? (nowMs - state.positionAt) : 0)) / 1000) : null;
const guestPosSec = Math.round((player.currentTime ?? 0));
const handleCopy = async () => {
const text = formatted || '(empty)';
try {
await navigator.clipboard.writeText(text);
showToast(t('orbit.diag.copied', { count: events.length }), 2500, 'info');
} catch {
showToast(t('orbit.diag.copyFailed'), 4000, 'error');
}
};
const handleClear = () => {
clearOrbitEvents();
showToast(t('orbit.diag.cleared'), 2000, 'info');
};
return createPortal(
<div ref={popRef} className="orbit-diag-pop" style={style} role="dialog" aria-label={t('orbit.diag.title')}>
<div className="orbit-diag-pop__head">{t('orbit.diag.title')}</div>
<div className="orbit-diag-pop__live">
<div className="orbit-diag-pop__live-row">
<span className="orbit-diag-pop__live-label">{t('orbit.diag.role')}</span>
<span>{role ?? '—'}</span>
</div>
<div className="orbit-diag-pop__live-row">
<span className="orbit-diag-pop__live-label">{t('orbit.diag.hostTrack')}</span>
<span className="orbit-diag-pop__mono">{state?.currentTrack?.trackId ?? '—'}</span>
</div>
<div className="orbit-diag-pop__live-row">
<span className="orbit-diag-pop__live-label">{t('orbit.diag.hostPos')}</span>
<span>{hostPosSec != null ? `${hostPosSec}s · ${state?.isPlaying ? '▶' : '⏸'}` : '—'}</span>
</div>
{role === 'guest' && (
<>
<div className="orbit-diag-pop__live-row">
<span className="orbit-diag-pop__live-label">{t('orbit.diag.guestTrack')}</span>
<span className="orbit-diag-pop__mono">{player.currentTrack?.id ?? '—'}</span>
</div>
<div className="orbit-diag-pop__live-row">
<span className="orbit-diag-pop__live-label">{t('orbit.diag.guestPos')}</span>
<span>{guestPosSec}s · {player.isPlaying ? '▶' : '⏸'}</span>
</div>
<div className="orbit-diag-pop__live-row">
<span className="orbit-diag-pop__live-label">{t('orbit.diag.drift')}</span>
<span>{driftMs != null ? `${(driftMs / 1000).toFixed(1)}s` : '—'}</span>
</div>
</>
)}
{hostStateAgeMs != null && (
<div className="orbit-diag-pop__live-row">
<span className="orbit-diag-pop__live-label">{t('orbit.diag.stateAge')}</span>
<span>{(hostStateAgeMs / 1000).toFixed(1)}s</span>
</div>
)}
</div>
<div className="orbit-diag-pop__log-head">
<span>{t('orbit.diag.eventLog', { count: events.length })}</span>
<div className="orbit-diag-pop__btn-row">
<button
type="button"
className="orbit-diag-pop__btn"
onClick={handleCopy}
data-tooltip={t('orbit.diag.copyTooltip')}
aria-label={t('orbit.diag.copyTooltip')}
>
<Copy size={13} />
<span>{t('orbit.diag.copyLabel')}</span>
</button>
<button
type="button"
className="orbit-diag-pop__btn"
onClick={handleClear}
data-tooltip={t('orbit.diag.clearTooltip')}
aria-label={t('orbit.diag.clearTooltip')}
>
<Trash2 size={13} />
<span>{t('orbit.diag.clearLabel')}</span>
</button>
</div>
</div>
<textarea
ref={taRef}
className="orbit-diag-pop__log"
readOnly
value={formatted}
spellCheck={false}
placeholder={t('orbit.diag.empty')}
/>
<div className="orbit-diag-pop__hint">{t('orbit.diag.hint')}</div>
</div>,
document.body,
);
}
@@ -0,0 +1,88 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { leaveOrbitSession } from '@/features/orbit/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 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 {
// Read role fresh from the store — the keydown effect binds once per
// open (deps [isOpen]) and would otherwise act on a stale role if it
// changed while the modal was up.
if (useOrbitStore.getState().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);
}, [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(
<div
className="modal-overlay orbit-exit-overlay"
onClick={e => { if (e.target === e.currentTarget) onOk(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-exit-title"
>
<div className="modal-content orbit-exit-modal">
<h3 id="orbit-exit-title" className="orbit-exit-modal__title">{title}</h3>
<p className="orbit-exit-modal__body">{body}</p>
<div className="orbit-exit-modal__actions">
<button type="button" className="btn btn-primary" onClick={onOk} autoFocus>{t('orbit.exitOk')}</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,197 @@
import { getSong } from '@/api/subsonicLibrary';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { useEffect, useMemo, useState } from 'react';
import { Radio, Clock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { TrackCoverArtImage } from '@/cover/TrackCoverArtImage';
import OrbitQueueHead from '@/features/orbit/components/OrbitQueueHead';
const ORBIT_QUEUE_COVER_LG_CSS_PX = 54;
const ORBIT_QUEUE_COVER_SM_CSS_PX = 48;
/**
* 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 = useMemo(() => state?.playQueue ?? [], [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<Record<string, SubsonicSong>>({});
// 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 (
<div className="orbit-guest-queue">
<OrbitQueueHead state={state} />
{currentTrack && (
<div className="orbit-guest-queue__current">
<div className="orbit-guest-queue__live-badge">
<Radio size={10} /> {t('orbit.guestLive')}
</div>
<div className="orbit-guest-queue__current-body">
{currentSong?.coverArt ? (
<TrackCoverArtImage
song={currentSong}
libraryResolve
displayCssPx={ORBIT_QUEUE_COVER_LG_CSS_PX}
surface="dense"
alt=""
className="orbit-guest-queue__cover orbit-guest-queue__cover--lg"
/>
) : (
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--lg orbit-guest-queue__cover--ph" />
)}
<div className="orbit-guest-queue__info">
<div className="orbit-guest-queue__track-title">
{currentSong?.title ?? t('orbit.guestLoading')}
</div>
<div className="orbit-guest-queue__track-artist">
{currentSong?.artist ?? ''}
</div>
<div className="orbit-guest-queue__note">
{state.isPlaying ? t('orbit.guestPlaying') : t('orbit.guestPaused')}
</div>
</div>
</div>
</div>
)}
{pending.length > 0 && (
<div className="orbit-guest-queue__pending">
<div className="orbit-guest-queue__section-head orbit-guest-queue__section-head--pending">
<Clock size={11} />
<span>{t('orbit.guestPendingTitle')}</span>
<span className="orbit-guest-queue__count">{pending.length}</span>
</div>
{pending.map(trackId => {
const song = songs[trackId];
return (
<div key={trackId} className="orbit-guest-queue__item orbit-guest-queue__item--pending">
{song?.coverArt ? (
<TrackCoverArtImage
song={song}
libraryResolve
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
surface="dense"
alt=""
className="orbit-guest-queue__cover"
/>
) : (
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--ph" />
)}
<div className="orbit-guest-queue__info">
<div className="orbit-guest-queue__track-title">{song?.title ?? '…'}</div>
<div className="orbit-guest-queue__track-artist">{song?.artist ?? ''}</div>
<div className="orbit-guest-queue__pending-hint">{t('orbit.guestPendingHint')}</div>
</div>
</div>
);
})}
</div>
)}
<div className="orbit-guest-queue__section-head">
{t('orbit.guestUpNext')} <span className="orbit-guest-queue__count">{totalUpcoming}</span>
</div>
<div className="orbit-guest-queue__list">
{queueItems.length === 0 && (
<div className="orbit-guest-queue__empty">{t('orbit.guestUpNextEmpty')}</div>
)}
{queueItems.map((q, i) => {
const song = songs[q.trackId];
const isHostPick = q.addedBy === state.host;
return (
<div
key={`${q.trackId}-${i}`}
className="orbit-guest-queue__item"
>
{song?.coverArt ? (
<TrackCoverArtImage
song={song}
libraryResolve
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
surface="dense"
alt=""
className="orbit-guest-queue__cover"
/>
) : (
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--ph" />
)}
<div className="orbit-guest-queue__info">
<div className="orbit-guest-queue__track-title">
{song?.title ?? '…'}
</div>
<div className="orbit-guest-queue__track-artist">
{song?.artist ?? ''}
</div>
<div className="orbit-guest-queue__submitter">
{isHostPick
? t('orbit.queueAddedByHost')
: t('orbit.guestSubmitter', { user: q.addedBy })}
</div>
</div>
</div>
);
})}
{truncatedBy > 0 && (
<div className="orbit-guest-queue__more">
{t('orbit.guestUpNextMore', { count: truncatedBy })}
</div>
)}
</div>
<div className="orbit-guest-queue__footer">{t('orbit.guestFooter')}</div>
</div>
);
}
@@ -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 '@/features/settings';
/**
* 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<HTMLDivElement>(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<HTMLElement>('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<HTMLElement>('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(
<div
className="modal-overlay"
onClick={e => { if (e.target === e.currentTarget) close(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-help-title"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div className="modal-content orbit-help-modal">
<button type="button" className="modal-close" onClick={close} aria-label={t('orbit.closeAria')}>
<X size={18} />
</button>
<h3 id="orbit-help-title" className="orbit-help-modal__title">
{t('orbit.helpTitle')}
</h3>
<p className="orbit-help-modal__intro">{t('orbit.helpIntro')}</p>
<div className="orbit-help-modal__body" ref={bodyRef}>
<SettingsSubSection title={t('orbit.helpSec1Title')} icon={<Sparkles size={16} />}>
<p>{t('orbit.helpSec1Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec2Title')} icon={<Users size={16} />}>
<p>{t('orbit.helpSec2Body')}</p>
<div className="orbit-help-modal__warn">
<strong>{t('orbit.helpSec2WarnHead')}</strong>
<span>{t('orbit.helpSec2WarnBody')}</span>
</div>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec3Title')} icon={<Share2 size={16} />}>
<p>{t('orbit.helpSec3Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec4Title')} icon={<LogIn size={16} />}>
<p>{t('orbit.helpSec4Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec5Title')} icon={<MousePointerClick size={16} />}>
<p>{t('orbit.helpSec5Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec6Title')} icon={<ListMusic size={16} />}>
<p>{t('orbit.helpSec6Body')}</p>
</SettingsSubSection>
<SettingsSubSection
title={`${t('orbit.helpSec7Title')} ${hostOnlyLabel}`}
icon={<Inbox size={16} />}
>
<p>{t('orbit.helpSec7Body')}</p>
</SettingsSubSection>
<SettingsSubSection
title={`${t('orbit.helpSec8Title')} ${hostOnlyLabel}`}
icon={<Sliders size={16} />}
>
<p>{t('orbit.helpSec8Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec9Title')} icon={<LogOut size={16} />}>
<p>{t('orbit.helpSec9Body')}</p>
</SettingsSubSection>
</div>
</div>
</div>,
document.body,
);
}
@@ -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 '@/features/orbit/utils/orbit';
import { switchActiveServer } from '@/utils/server/switchActiveServer';
import { useOrbitAccountPickerStore } from '@/features/orbit/store/orbitAccountPickerStore';
import { showToast } from '@/utils/ui/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<string | null>(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(
<div
className="modal-overlay orbit-start-overlay"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-join-title"
>
<div className="modal-content orbit-start-modal">
<button type="button" className="modal-close" onClick={onClose} aria-label={t('orbit.closeAria')}>
<X size={18} />
</button>
<div className="orbit-start-modal__hero">
<div className="orbit-start-modal__hero-icon">
<LogIn size={24} />
</div>
<h3 id="orbit-join-title" className="orbit-start-modal__title">
{t('orbit.joinModalTitle')}
</h3>
<p className="orbit-start-modal__sub">{t('orbit.joinModalSub')}</p>
</div>
<div className="orbit-start-modal__field">
<label className="orbit-start-modal__label" htmlFor="orbit-join-link">
{t('orbit.joinModalLinkLabel')}
</label>
<div className="orbit-start-modal__input-row">
<input
id="orbit-join-link"
type="text"
autoFocus
value={link}
onChange={e => { setLink(e.target.value); setError(null); }}
onKeyDown={e => { if (e.key === 'Enter' && !busy) void onJoin(); }}
placeholder={t('orbit.joinModalLinkPlaceholder')}
className="orbit-start-modal__input"
/>
<button
type="button"
className="orbit-start-modal__reshuffle"
onClick={onPaste}
data-tooltip={t('orbit.joinModalPasteTooltip')}
aria-label={t('orbit.joinModalPasteTooltip')}
>
<ClipboardPaste size={15} />
</button>
</div>
<div className="orbit-start-modal__helper">{t('orbit.joinModalLinkHelper')}</div>
</div>
{error && <div className="orbit-start-modal__error">{error}</div>}
<div className="orbit-start-modal__actions">
<button type="button" className="btn btn-surface" onClick={onClose}>
{t('orbit.btnCancel')}
</button>
<button
type="button"
className="btn btn-primary"
onClick={onJoin}
disabled={busy || !link.trim()}
>
{busy ? t('orbit.joinModalBusy') : t('orbit.joinModalSubmit')}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,161 @@
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 '@/features/orbit/store/orbitStore';
import { kickOrbitParticipant, removeOrbitParticipant, setOrbitSuggestionBlocked } from '@/features/orbit/utils/orbit';
import ConfirmModal from '@/components/ConfirmModal';
interface Props {
/** Anchor — we position the popover directly below its bottom-right. */
anchorRef: React.RefObject<HTMLElement | null>;
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<HTMLDivElement>(null);
const [confirm, setConfirm] = useState<{ user: string; mode: 'remove' | 'ban' } | null>(null);
// React Compiler purity rule: intentional live-timestamp read at render (Date.now()); the value is allowed to differ between renders.
// eslint-disable-next-line react-hooks/purity
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;
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
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(
<>
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
<div className="orbit-participants-pop__head">
{t('orbit.participantsCountLabel', { count: state.participants.length + 1 })}
</div>
<div className="orbit-participants-pop__row orbit-participants-pop__row--host">
<Crown size={13} />
<span className="orbit-participants-pop__name">{state.host}</span>
<span className="orbit-participants-pop__meta">{t('orbit.participantsHost')}</span>
</div>
{state.participants.length === 0 && (
<div className="orbit-participants-pop__empty">{t('orbit.participantsEmpty')}</div>
)}
{state.participants.map(p => {
const isMuted = state.suggestionBlocked?.includes(p.user) ?? false;
return (
<div key={p.user} className="orbit-participants-pop__row">
<User size={13} />
<span className="orbit-participants-pop__name">{p.user}</span>
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
{role === 'host' && (
<div className="orbit-participants-pop__actions">
<button
type="button"
className={`orbit-participants-pop__kick${isMuted ? ' is-active' : ''}`}
onClick={() => { void setOrbitSuggestionBlocked(p.user, !isMuted); }}
data-tooltip={isMuted ? t('orbit.participantsUnmuteTooltip') : t('orbit.participantsMuteTooltip')}
aria-label={isMuted
? t('orbit.participantsUnmuteAria', { user: p.user })
: t('orbit.participantsMuteAria', { user: p.user })}
aria-pressed={isMuted}
>
{isMuted ? <MicOff size={12} /> : <Mic size={12} />}
</button>
<button
type="button"
className="orbit-participants-pop__kick"
onClick={() => setConfirm({ user: p.user, mode: 'remove' })}
data-tooltip={t('orbit.participantsRemoveTooltip')}
aria-label={t('orbit.participantsRemoveAria', { user: p.user })}
>
<UserMinus size={12} />
</button>
<button
type="button"
className="orbit-participants-pop__kick orbit-participants-pop__kick--ban"
onClick={() => setConfirm({ user: p.user, mode: 'ban' })}
data-tooltip={t('orbit.participantsBanTooltip')}
aria-label={t('orbit.participantsBanAria', { user: p.user })}
>
<ShieldOff size={12} />
</button>
</div>
)}
</div>
);
})}
</div>
<ConfirmModal
open={!!confirm}
title={confirm?.mode === 'ban'
? t('orbit.confirmBanTitle')
: t('orbit.confirmRemoveTitle')}
message={confirm?.mode === 'ban'
? t('orbit.confirmBanBody', { user: confirm?.user ?? '' })
: t('orbit.confirmRemoveBody', { user: confirm?.user ?? '' })}
confirmLabel={confirm?.mode === 'ban'
? t('orbit.confirmBanConfirm')
: t('orbit.confirmRemoveConfirm')}
cancelLabel={t('orbit.confirmCancel')}
danger={confirm?.mode === 'ban'}
onConfirm={() => { void onConfirm(); }}
onCancel={() => setConfirm(null)}
/>
</>,
document.body,
);
}
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { Users, Wifi, WifiOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import type { OrbitState } from '@/features/orbit/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 (
<div className="orbit-queue-head">
<div className="orbit-queue-head__title-row">
<h2 className="orbit-queue-head__title">{state.name}</h2>
{showPresence && (
<span
className={`orbit-queue-head__presence orbit-queue-head__presence--${hostAway ? 'away' : 'online'}`}
role="status"
>
{hostAway ? <WifiOff size={11} /> : <Wifi size={11} />}
<span>{t(hostAway ? 'orbit.hostAway' : 'orbit.hostOnline')}</span>
</span>
)}
</div>
<div className="orbit-queue-head__meta">
<Users size={11} />
<span className="orbit-queue-head__names">{names.join(', ')}</span>
</div>
</div>
);
}
@@ -0,0 +1,375 @@
import { getSong } from '@/api/subsonicLibrary';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useEffect, useRef, useState } from 'react';
import { X, RefreshCw, Shuffle, Settings2, Share2, HelpCircle, Activity } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { useHelpModalStore } from '@/store/helpModalStore';
import { usePlayerStore } from '@/store/playerStore';
import {
endOrbitSession,
leaveOrbitSession,
computeOrbitDriftMs,
effectiveShuffleIntervalMs,
} from '@/features/orbit/utils/orbit';
import { estimateLivePosition } from '@/features/orbit/api/orbit';
import OrbitParticipantsPopover from '@/features/orbit/components/OrbitParticipantsPopover';
import OrbitExitModal from '@/features/orbit/components/OrbitExitModal';
import OrbitSettingsPopover from '@/features/orbit/components/OrbitSettingsPopover';
import OrbitSharePopover from '@/features/orbit/components/OrbitSharePopover';
import OrbitDiagnosticsPopover from '@/features/orbit/components/OrbitDiagnosticsPopover';
import ConfirmModal from '@/components/ConfirmModal';
import { formatTrackTime } from '@/utils/format/formatDuration';
/**
* 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;
/** `m:ss` countdown from a millisecond value. */
function formatCountdown(ms: number): string {
return formatTrackTime(Math.round(ms / 1000));
}
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 [diagOpen, setDiagOpen] = useState(false);
const [confirmLeave, setConfirmLeave] = useState(false);
const peopleBtnRef = useRef<HTMLButtonElement>(null);
const settingsBtnRef = useRef<HTMLButtonElement>(null);
const shareBtnRef = useRef<HTMLButtonElement>(null);
const diagBtnRef = useRef<HTMLButtonElement>(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]);
// ── Catch Up button visibility — debounced + hysteresis ───────────────
// The raw drift signal is noisy: guest's `currentTime` updates in coarse
// ~5 s chunks while host's position is extrapolated linearly via
// `(nowMs - posAt)`, so the diff swings between ~1 s and ~8 s every
// tick on a normal session even when both sides are perfectly synced.
// Two-stage filter:
// - **Hidden → shown**: drift must stay over the show-threshold (3 s)
// for 3 s of wall-clock. Filters out brief over-threshold blips.
// - **Shown → hidden**: drift must stay under the hide-threshold
// (1 s) for 1 s of wall-clock. Once visible, the button persists
// through the 13 s "drift back to small" valleys that come from
// guest's currentTime catching up in chunks; otherwise the button
// would vanish too fast to actually click on a high-latency
// session where genuine drift fluctuates around 58 s.
const SHOW_THRESHOLD_MS = CATCH_UP_DRIFT_THRESHOLD_MS;
const HIDE_THRESHOLD_MS = 1_000;
const SHOW_DEBOUNCE_MS = 3_000;
const HIDE_DEBOUNCE_MS = 1_000;
const [showCatchUp, setShowCatchUp] = useState(false);
const overSinceRef = useRef<number | null>(null);
const underSinceRef = useRef<number | null>(null);
useEffect(() => {
// Note: `state.isPlaying` is *not* a gate. A guest who joined while
// the host was paused still benefits from Catch Up if their sync to
// the host's paused position failed — the only signal that matters
// is "is there drift between us and the host's last reported state".
// `computeOrbitDriftMs` correctly stops time-extrapolation when the
// host is paused, so the formula holds in both states.
if (role !== 'guest' || !state || !state.currentTrack) {
overSinceRef.current = null;
underSinceRef.current = null;
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowCatchUp(false);
return;
}
const player = usePlayerStore.getState();
const localPositionMs = Math.round((player.currentTime ?? 0) * 1000);
const driftMs = player.currentTrack?.id === state.currentTrack.trackId
? computeOrbitDriftMs(state, localPositionMs, nowMs)
: null;
const absDrift = driftMs == null ? Infinity : Math.abs(driftMs);
if (showCatchUp) {
// Currently visible — only hide once drift has been clearly small
// for the full hide-debounce window.
overSinceRef.current = null;
if (absDrift < HIDE_THRESHOLD_MS) {
if (underSinceRef.current === null) underSinceRef.current = Date.now();
if (Date.now() - underSinceRef.current >= HIDE_DEBOUNCE_MS) {
setShowCatchUp(false);
underSinceRef.current = null;
}
} else {
underSinceRef.current = null;
}
} else {
// Currently hidden — only show after sustained over-threshold drift.
underSinceRef.current = null;
if (absDrift > SHOW_THRESHOLD_MS) {
if (overSinceRef.current === null) overSinceRef.current = Date.now();
if (Date.now() - overSinceRef.current >= SHOW_DEBOUNCE_MS) {
setShowCatchUp(true);
overSinceRef.current = null;
}
} else {
overSinceRef.current = null;
}
}
}, [role, state, nowMs, showCatchUp, SHOW_THRESHOLD_MS]);
// 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 (
<OrbitExitModal />
);
const untilShuffle = Math.max(0, (state.lastShuffle + effectiveShuffleIntervalMs(state)) - nowMs);
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` debounces the underlying `audio_seek` invoke via
// `setTimeout(0)`, while `pause`/`resume` fire their invokes
// synchronously. Calling them back-to-back races on the Tauri
// command queue: pause/resume can arrive at the engine before
// the seek does, leaving the engine paused at the *old*
// position with the seek queued behind it — when the user
// hits play later the engine resumes from the pre-Catch-Up
// spot and the waveform jumps back. Defer the play-state
// mirror by one short tick so the seek lands first.
player.seek(fraction);
if (hostPlaying !== player.isPlaying) {
window.setTimeout(() => {
const p = usePlayerStore.getState();
if (p.currentTrack?.id !== trackId) return;
if (hostPlaying && !p.isPlaying) p.resume();
else if (!hostPlaying && p.isPlaying) p.pause();
}, 200);
}
} else {
// Different track: play + seek once the engine reports the track
// loaded. The previous 400 ms blind delay was too short for an
// HTTP-streamed cold-start on a transcontinental link, so the seek
// would silently no-op and playback started at 0:00 — making Catch
// Up effectively useless on the very latency where it matters most.
// Mirrors the poll-until-ready pattern used by `syncToHost`.
player.playTrack(track, [track]);
const deadline = Date.now() + 4000;
const poll = () => {
const p = usePlayerStore.getState();
if (p.currentTrack?.id !== trackId) return; // user changed tracks
if (p.isPlaying || Date.now() >= deadline) {
p.seek(fraction);
if (!hostPlaying && p.isPlaying) p.pause();
return;
}
window.setTimeout(poll, 100);
};
window.setTimeout(poll, 100);
}
} 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 (
<div className="orbit-bar">
<div className="orbit-bar__left">
<span className="orbit-bar__dot" aria-hidden="true" />
<span className="orbit-bar__name">{state.name}</span>
<span className="orbit-bar__sep">·</span>
<button
ref={peopleBtnRef}
type="button"
className="orbit-bar__count"
onClick={() => setPeopleOpen(v => !v)}
data-tooltip={t('orbit.participantsTooltip')}
aria-haspopup="menu"
aria-expanded={peopleOpen || undefined}
>
{participantCount}/{state.maxUsers}
</button>
<span className="orbit-bar__sep">·</span>
<span className="orbit-bar__host">{t('orbit.hostLabel', { name: state.host })}</span>
</div>
<div className="orbit-bar__center">
<span className="orbit-bar__shuffle">
<Shuffle size={13} className="orbit-bar__shuffle-icon" />
<span>{t('orbit.shuffleLabel')}</span>
<strong className="orbit-bar__shuffle-time">{formatCountdown(untilShuffle)}</strong>
</span>
</div>
<div className="orbit-bar__right">
{role === 'host' && (
<button
ref={settingsBtnRef}
type="button"
className="orbit-bar__settings"
onClick={() => setSettingsOpen(v => !v)}
data-tooltip={t('orbit.settingsTooltip')}
aria-haspopup="menu"
aria-expanded={settingsOpen || undefined}
>
<Settings2 size={14} />
</button>
)}
{role === 'host' && (
<button
ref={shareBtnRef}
type="button"
className="orbit-bar__settings"
onClick={() => setShareOpen(v => !v)}
data-tooltip={t('orbit.shareTooltip')}
aria-haspopup="menu"
aria-expanded={shareOpen || undefined}
aria-label={t('orbit.shareTooltip')}
>
<Share2 size={14} />
</button>
)}
{showCatchUp && (
<button
type="button"
className="orbit-bar__catchup"
onClick={onCatchUp}
data-tooltip={t('orbit.catchUpTooltip')}
>
<RefreshCw size={13} />
<span>{t('orbit.catchUpLabel')}</span>
</button>
)}
<button
ref={diagBtnRef}
type="button"
className="orbit-bar__settings"
onClick={() => setDiagOpen(v => !v)}
data-tooltip={t('orbit.diag.openTooltip')}
aria-haspopup="dialog"
aria-expanded={diagOpen || undefined}
aria-label={t('orbit.diag.openTooltip')}
>
<Activity size={14} />
</button>
<button
type="button"
className="orbit-bar__settings"
onClick={() => useHelpModalStore.getState().open()}
data-tooltip={t('orbit.helpTooltip')}
aria-label={t('orbit.helpTooltip')}
>
<HelpCircle size={14} />
</button>
<button
type="button"
className="orbit-bar__exit"
onClick={onExit}
data-tooltip={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
aria-label={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
>
<X size={15} />
</button>
</div>
{peopleOpen && (
<OrbitParticipantsPopover
anchorRef={peopleBtnRef}
onClose={() => setPeopleOpen(false)}
/>
)}
{settingsOpen && (
<OrbitSettingsPopover
anchorRef={settingsBtnRef}
onClose={() => setSettingsOpen(false)}
/>
)}
{shareOpen && (
<OrbitSharePopover
anchorRef={shareBtnRef}
onClose={() => setShareOpen(false)}
/>
)}
{diagOpen && (
<OrbitDiagnosticsPopover
anchorRef={diagBtnRef}
onClose={() => setDiagOpen(false)}
/>
)}
<OrbitExitModal />
<ConfirmModal
open={confirmLeave}
title={role === 'host'
? t('orbit.confirmEndTitle')
: t('orbit.confirmLeaveTitle')}
message={role === 'host'
? t('orbit.confirmEndBody', { name: state.name })
: t('orbit.confirmLeaveBody', { name: state.name, host: state.host })}
confirmLabel={role === 'host'
? t('orbit.confirmEndConfirm')
: t('orbit.confirmLeaveConfirm')}
cancelLabel={t('orbit.confirmCancel')}
danger={role === 'host'}
onConfirm={() => { setConfirmLeave(false); void performExit(); }}
onCancel={() => setConfirmLeave(false)}
/>
</div>
);
}
@@ -0,0 +1,133 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Shuffle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { updateOrbitSettings, triggerOrbitShuffleNow } from '@/features/orbit/utils/orbit';
import { ORBIT_DEFAULT_SETTINGS, ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN, type OrbitShuffleIntervalMin } from '@/features/orbit/api/orbit';
import { showToast } from '@/utils/ui/toast';
interface Props {
anchorRef: React.RefObject<HTMLElement | null>;
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<HTMLDivElement>(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]);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
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(
<div ref={popRef} className="orbit-settings-pop" style={style} role="menu">
<div className="orbit-settings-pop__head">{t('orbit.settingsTitle')}</div>
<label className="orbit-settings-pop__row">
<div className="orbit-settings-pop__text">
<div className="orbit-settings-pop__label">{t('orbit.settingAutoApprove')}</div>
<div className="orbit-settings-pop__hint">{t('orbit.settingAutoApproveHint')}</div>
</div>
<span className="toggle-switch">
<input
type="checkbox"
checked={settings.autoApprove}
onChange={e => { void updateOrbitSettings({ autoApprove: e.target.checked }); }}
/>
<span className="toggle-track" />
</span>
</label>
<label className="orbit-settings-pop__row">
<div className="orbit-settings-pop__text">
<div className="orbit-settings-pop__label">{t('orbit.settingAutoShuffle')}</div>
<div className="orbit-settings-pop__hint">{t('orbit.settingAutoShuffleHint')}</div>
</div>
<span className="toggle-switch">
<input
type="checkbox"
checked={settings.autoShuffle}
onChange={e => { void updateOrbitSettings({ autoShuffle: e.target.checked }); }}
/>
<span className="toggle-track" />
</span>
</label>
<div className="orbit-settings-pop__row orbit-settings-pop__row--stacked">
<div className="orbit-settings-pop__text">
<div className="orbit-settings-pop__label">{t('orbit.settingShuffleInterval')}</div>
<div className="orbit-settings-pop__hint">{t('orbit.settingShuffleIntervalHint')}</div>
</div>
<div
className="orbit-settings-pop__preset-group"
role="radiogroup"
aria-label={t('orbit.settingShuffleInterval')}
>
{ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN.map(min => {
const active = (settings.shuffleIntervalMin ?? 15) === min;
return (
<button
key={min}
type="button"
role="radio"
aria-checked={active}
className={`orbit-settings-pop__preset${active ? ' is-active' : ''}`}
disabled={!settings.autoShuffle}
onClick={() => {
void updateOrbitSettings({ shuffleIntervalMin: min as OrbitShuffleIntervalMin });
}}
>
{t('orbit.settingShuffleIntervalValue', { count: min })}
</button>
);
})}
</div>
</div>
<button
type="button"
className="orbit-settings-pop__action"
onClick={() => {
void triggerOrbitShuffleNow();
showToast(t('orbit.toastShuffled'), 2200, 'info');
onClose();
}}
>
<Shuffle size={13} />
<span>{t('orbit.settingShuffleNow')}</span>
</button>
</div>,
document.body,
);
}
@@ -0,0 +1,90 @@
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 '@/features/orbit/store/orbitStore';
import { useAuthStore } from '@/store/authStore';
import { buildOrbitShareLink } from '@/features/orbit/utils/orbit';
import { serverShareBaseUrl } from '@/utils/server/serverEndpoint';
interface Props {
anchorRef: React.RefObject<HTMLElement | null>;
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<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const shareLink = sessionId
? (() => {
const active = useAuthStore.getState().getActiveServer();
return buildOrbitShareLink(active ? serverShareBaseUrl(active) : '', 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;
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
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(
<div ref={popRef} className="orbit-share-pop" style={style} role="menu">
<div className="orbit-share-pop__label">{t('orbit.participantsInviteLabel')}</div>
<div className="orbit-share-pop__row">
<code className="orbit-share-pop__link">{shareLink}</code>
<button
type="button"
className="orbit-share-pop__copy"
onClick={onCopy}
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
aria-label={t('orbit.ariaCopyLink')}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,238 @@
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 '@/features/orbit/utils/orbit';
import { randomOrbitSessionName } from '@/features/orbit/utils/orbitNames';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/store/playerStore';
import { isLanUrl, serverShareBaseUrl } from '@/utils/server/serverEndpoint';
import { ORBIT_DEFAULT_MAX_USERS } from '@/features/orbit/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<string | null>(null);
const [copied, setCopied] = useState(false);
const [hasCopied, setHasCopied] = useState(false);
const [clearQueue, setClearQueue] = useState(false);
const server = useAuthStore.getState().getActiveServer();
// Orbit links go to remote guests — use the share URL (public by default
// when both are set; LAN only if shareUsesLocalUrl is on). The LAN warning
// then correctly reads the address the guest will actually see.
const serverBase = server ? serverShareBaseUrl(server) : '';
const serverName = server?.name ?? server?.url ?? t('orbit.fallbackServer');
const onLan = isLanUrl(serverBase);
const shareLink = useMemo(
() => buildOrbitShareLink(serverBase, sid),
// React Compiler rule: manual memoization is intentional and must be preserved.
// eslint-disable-next-line react-hooks/preserve-manual-memoization
[serverBase, sid],
);
const writeLinkToClipboard = async (): Promise<boolean> => {
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(
<div
className="modal-overlay orbit-start-overlay"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-start-title"
>
<div className="modal-content orbit-start-modal">
<button type="button" className="modal-close" onClick={onClose} aria-label={t('orbit.closeAria')}>
<X size={18} />
</button>
<div className="orbit-start-modal__hero">
<div className="orbit-start-modal__hero-icon">
<OrbitIcon size={24} />
</div>
<h3 id="orbit-start-title" className="orbit-start-modal__title">
{t('orbit.heroTitlePrefix')}{' '}
<span className="orbit-start-modal__brand">{t('orbit.heroTitleBrand')}</span>
</h3>
<p className="orbit-start-modal__sub">
{heroSubParts[0]}
<strong>{serverName}</strong>
{heroSubParts[1] ?? ''}
</p>
</div>
<div
className={`orbit-start-modal__tip${onLan ? ' orbit-start-modal__tip--warn' : ''}`}
role={onLan ? 'alert' : undefined}
>
{onLan ? <AlertTriangle size={15} /> : <Globe2 size={15} />}
<span>{onLan ? t('orbit.tipLan') : t('orbit.tipRemote')}</span>
</div>
<div className="orbit-start-modal__field">
<label className="orbit-start-modal__label" htmlFor="orbit-name">
{t('orbit.labelName')}
</label>
<div className="orbit-start-modal__input-row">
<input
id="orbit-name"
type="text"
autoFocus
value={name}
onChange={e => { 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"
/>
<button
type="button"
className="orbit-start-modal__reshuffle"
onClick={() => { setName(randomOrbitSessionName()); setHasCopied(false); }}
data-tooltip={t('orbit.reshuffleTooltip')}
aria-label={t('orbit.reshuffleAria')}
>
<Dices size={15} />
</button>
</div>
<div className="orbit-start-modal__helper">{t('orbit.helperName')}</div>
</div>
<div className="orbit-start-modal__field">
<label className="orbit-start-modal__label" htmlFor="orbit-max">
{t('orbit.labelMax')}: <strong>{maxUsers}</strong>
</label>
<input
id="orbit-max"
type="range"
min={1}
max={32}
value={maxUsers}
onChange={e => setMaxUsers(Number(e.target.value))}
className="orbit-start-modal__range"
/>
<div className="orbit-start-modal__helper">{t('orbit.helperMax')}</div>
</div>
<div className="orbit-start-modal__field">
<label className="orbit-start-modal__toggle-row">
<div className="orbit-start-modal__toggle-text">
<div className="orbit-start-modal__label">{t('orbit.labelClearQueue')}</div>
<div className="orbit-start-modal__helper">{t('orbit.helperClearQueue')}</div>
</div>
<span className="toggle-switch">
<input
type="checkbox"
checked={clearQueue}
onChange={e => setClearQueue(e.target.checked)}
/>
<span className="toggle-track" />
</span>
</label>
</div>
<div className="orbit-start-modal__field">
<label className="orbit-start-modal__label">{t('orbit.labelLink')}</label>
<div className="orbit-start-modal__link">
<code>{shareLink}</code>
<button
type="button"
className="orbit-start-modal__copy"
onClick={onCopy}
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
aria-label={t('orbit.ariaCopyLink')}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
<div className="orbit-start-modal__helper">{t('orbit.helperLink')}</div>
</div>
{error && <div className="orbit-start-modal__error">{error}</div>}
<div className="orbit-start-modal__actions">
<button type="button" className="btn btn-surface" onClick={onClose}>
{t('orbit.btnCancel')}
</button>
<button
type="button"
className="btn btn-primary"
onClick={onStart}
disabled={busy || !name.trim()}
>
{busy
? t('orbit.btnStarting')
: hasCopied ? t('orbit.btnStart') : t('orbit.btnCopyAndStart')}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,112 @@
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 '@/features/orbit/store/orbitStore';
import { useAuthStore } from '@/store/authStore';
import { useHelpModalStore } from '@/store/helpModalStore';
import OrbitStartModal from '@/features/orbit/components/OrbitStartModal';
import OrbitJoinModal from '@/features/orbit/components/OrbitJoinModal';
import OrbitWordmark from '@/features/orbit/components/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<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(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;
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
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 (
<>
<button
ref={btnRef}
type="button"
className="btn btn-surface orbit-start-trigger"
onClick={() => setPopoverOpen(v => !v)}
data-tooltip={t('orbit.triggerTooltip')}
data-tooltip-pos="bottom"
aria-haspopup="menu"
aria-expanded={popoverOpen || undefined}
aria-label={t('orbit.triggerLabel')}
style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }}
>
<OrbitIcon size={18} className="orbit-start-trigger__spin" />
<span className="orbit-start-trigger__label" style={{ display: 'inline-flex', alignItems: 'center', height: '1.5em' }}>
<OrbitWordmark height={14} />
</span>
</button>
{popoverOpen && createPortal(
<div ref={popRef} className="nav-library-dropdown-panel orbit-launch-pop" style={popoverStyle} role="menu">
<button type="button" className="orbit-launch-pop__item" onClick={pickCreate}>
<Plus size={14} />
<span>{t('orbit.launchCreate')}</span>
</button>
<button type="button" className="orbit-launch-pop__item" onClick={pickJoin}>
<LogIn size={14} />
<span>{t('orbit.launchJoin')}</span>
</button>
<button
type="button"
className="orbit-launch-pop__item"
onClick={pickHelp}
>
<HelpCircle size={14} />
<span>{t('orbit.launchHelp')}</span>
</button>
</div>,
document.body,
)}
{startOpen && <OrbitStartModal onClose={() => setStartOpen(false)} />}
{joinOpen && <OrbitJoinModal onClose={() => setJoinOpen(false)} />}
</>
);
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
import { useEffect } from 'react';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
/**
* Mirror the live Orbit role + phase onto `<html data-orbit-active>` and
* `<html data-orbit-role>` so global CSS can hide controls that conflict
* with an active Orbit session (e.g. track preview steps on shared
* playback) or style host-vs-guest UI states (read-only guest seekbar).
* Covers any pre-`active` phase so the marker spans the join lifecycle.
*/
export function useOrbitBodyAttrs(): void {
const orbitRole = useOrbitStore(s => s.role);
const orbitPhase = useOrbitStore(s => s.phase);
useEffect(() => {
const inOrbit = (orbitRole === 'host' || orbitRole === 'guest')
&& (orbitPhase === 'active' || orbitPhase === 'joining' || orbitPhase === 'starting');
if (inOrbit) {
document.documentElement.setAttribute('data-orbit-active', 'true');
document.documentElement.setAttribute('data-orbit-role', orbitRole as string);
} else {
document.documentElement.removeAttribute('data-orbit-active');
document.documentElement.removeAttribute('data-orbit-role');
}
}, [orbitRole, orbitPhase]);
}
+414
View File
@@ -0,0 +1,414 @@
import { getSong } from '@/api/subsonicLibrary';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useEffect, useRef } from 'react';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/store/playerStore';
import {
readOrbitState,
applyOrbitTransitionSettings,
saveGuestTransitionsOnce,
restoreGuestTransitions,
ensureTrackInOutbox,
planPendingResends,
forgetPendingSuggestion,
resetPendingResendState,
} from '@/features/orbit/utils/orbit';
import { showToast } from '@/utils/ui/toast';
import i18n from '@/i18n';
import { estimateLivePosition, type OrbitState } from '@/features/orbit/api/orbit';
import { pushOrbitEvent } from '@/features/orbit/utils/orbitDiag';
import { useOrbitOutboxHeartbeat } from '@/features/orbit/hooks/useOrbitOutboxHeartbeat';
/**
* Orbit — guest-side tick hook.
*
* Mounted at the app shell; only does work when the local store says we're
* a guest in an active session. Two independent timers:
*
* - **State read** (2.5 s): pull the canonical state from the session
* playlist's comment and mirror it into the store. Detect session end
* or own kick and tear down.
* - **Heartbeat** (10 s): refresh the guest's own outbox playlist
* comment so the host's participant sweep sees the user as alive.
*
* Reads are best-effort; a transient Navidrome outage just delays state
* updates by a tick or two. The session continues locally as long as the
* playback engine has the current track loaded.
*/
const STATE_READ_TICK_MS = 2_500;
/**
* Host must be quiet (no state writes) for this long before we treat the
* session as dead and auto-leave. Well above any normal network blip —
* reconnects inside this window are silent. Tuned per user decision:
* manual exits have priority, short reconnects never trigger auto-close.
*/
const HOST_TIMEOUT_MS = 5 * 60_000;
export function useOrbitGuest(): void {
const role = useOrbitStore(s => s.role);
const phase = useOrbitStore(s => s.phase);
const sessionPlaylistId = useOrbitStore(s => s.sessionPlaylistId);
const outboxPlaylistId = useOrbitStore(s => s.outboxPlaylistId);
const sessionId = useOrbitStore(s => s.sessionId);
const myName = useAuthStore(s => s.getActiveServer()?.username);
const active = role === 'guest' && phase === 'active' && !!sessionPlaylistId;
/**
* Last host playback state we *applied* to the local player. Compared
* against the new tick to detect host-side flips (track change /
* play-pause toggle) and against the local player's current state to
* detect guest-side divergence (the guest paused or skipped on their own).
*
* Reset to null on (re-)activation so a fresh session re-syncs from scratch.
*/
const lastAppliedRef = useRef<{ trackId: string | null; isPlaying: boolean } | null>(null);
// ── State read + end/kick detection + auto-sync to host ──────────────
useEffect(() => {
if (!active || !sessionPlaylistId) return;
let cancelled = false;
lastAppliedRef.current = null;
// Snapshot the user's own transition prefs once, before the first tick
// adopts the host's — restored on leave by this effect's cleanup.
saveGuestTransitionsOnce();
/**
* Load `trackId` into the local player and seek to the host's live
* position. Mirrors the host's `isPlaying` state — a guest joining a
* paused host doesn't auto-start, a guest joining a playing host must
* start. Best-effort; silent on miss.
*
* Seek + state-mirror is applied once the engine reports the target
* track as `isPlaying` (polled up to 2 s), with a final fallback apply
* past the deadline so a loading error doesn't leave the guest stuck
* on a silent pause.
*/
const syncToHost = async (trackId: string, hostState: OrbitState): Promise<boolean> => {
try {
const song = await getSong(trackId);
if (!song || cancelled) return false;
const track = songToTrack(song);
// Clamp fraction to [0, 0.99] — if the host's positionAt is unusually
// stale, estimateLivePosition can overshoot the track duration and a
// seek past the end would immediately trigger audio:ended.
const calcFraction = () => {
const targetMs = estimateLivePosition(hostState, Date.now());
const targetSec = Math.max(0, targetMs / 1000);
return Math.max(0, Math.min(0.99, targetSec / Math.max(1, track.duration)));
};
const applyMirror = (): boolean => {
const p = usePlayerStore.getState();
if (cancelled || p.currentTrack?.id !== trackId) return false;
p.seek(calcFraction());
// Defer the play-state mirror so the seek's `audio_seek` invoke
// arrives at the engine before pause/resume. `player.seek` is
// debounced via setTimeout(0); `pause`/`resume` fire their
// invokes synchronously — without the delay the play-state
// change can race ahead of the seek and leave the engine in
// the wrong position.
if (hostState.isPlaying !== p.isPlaying) {
window.setTimeout(() => {
const fresh = usePlayerStore.getState();
if (cancelled || fresh.currentTrack?.id !== trackId) return;
if (hostState.isPlaying && !fresh.isPlaying) fresh.resume();
else if (!hostState.isPlaying && fresh.isPlaying) fresh.pause();
}, 200);
}
return true;
};
const player = usePlayerStore.getState();
const sameTrack = player.currentTrack?.id === trackId;
// Take the cheap path only when the engine is actually in the
// state the host expects. If the track is loaded but the engine
// never reported `isPlaying === true` (slow cold-start, audio-
// device warmup), this branch used to fire `seek` + `resume`
// into a stuck engine — the seek silently no-oped and `resume`
// can't restart a track that never started. Result: guest sees
// "synced" but hears nothing until the next host-driven track
// change kicks a fresh `playTrack`. Falling through to a fresh
// `playTrack` here re-initialises the engine instead.
if (sameTrack && player.isPlaying === hostState.isPlaying) {
return applyMirror();
}
if (sameTrack && player.isPlaying && !hostState.isPlaying) {
// We're playing but host is paused — pause locally without
// re-loading the track.
return applyMirror();
}
player.playTrack(track, [track]);
// Poll until the engine actually reports the track playing — the
// earlier "blind apply at deadline" path could fire a seek into a
// not-yet-ready engine, where the seek silently no-ops and the
// guest plays from 0 while believing they're synced (the visible
// 50 % jump-on-Catch-Up symptom). Wait for `p.isPlaying === true`
// up to 5 s, then give up and let the outer pull tick retry —
// the syncToHost-failed path keeps `lastAppliedRef` null so the
// 500 ms fast-poll in `tick` will try again immediately.
return await new Promise<boolean>(resolve => {
const deadline = Date.now() + 5000;
const poll = () => {
if (cancelled) { resolve(false); return; }
const p = usePlayerStore.getState();
const trackReady = p.currentTrack?.id === trackId;
// Wait for the engine to *actually* be playing, not just for
// `isPlaying = true` (which `playTrack` flips synchronously
// before the audio engine has produced a single sample).
// Also require `currentTime > 0.1` — once audio has flowed
// past the cold-start barrier, the engine is genuinely
// playing and a `seek` will commit. Without this check the
// seek inside `applyMirror` lands on a not-yet-ready engine,
// silently no-ops, and the engine's first progress events
// overwrite the optimistic store position — the visible
// symptom on join is "the waveform shows the host's live
// position for a second, then snaps back to 0:00".
const enginePlaying = trackReady
&& p.isPlaying
&& (p.currentTime ?? 0) > 0.1;
if (enginePlaying) { resolve(applyMirror()); return; }
if (Date.now() >= deadline) { resolve(false); return; }
window.setTimeout(poll, 100);
};
window.setTimeout(poll, 100);
});
} catch { return false; }
};
const pull = async () => {
const state = await readOrbitState(sessionPlaylistId);
if (cancelled) return;
if (!state) {
// Session playlist is gone — almost always means the host ended the
// session and the `ended:true` write was missed because we polled
// after the subsequent playlist delete. Surface the same modal the
// explicit `state.ended` branch does; the store still holds the last
// known state so the modal can render the host + session name copy.
// Outbox cleanup runs from the modal's OK handler via leaveOrbitSession.
pushOrbitEvent('pull', 'state read returned null — playlist gone, ending session');
useOrbitStore.getState().setPhase('ended');
return;
}
useOrbitStore.getState().setState(state);
// Adopt the host's track-transition prefs for the session — idempotent,
// only writes when they actually changed. Absent on pre-transition-sync
// hosts, in which case the guest keeps its own.
if (state.settings?.transitions) {
applyOrbitTransitionSettings(state.settings.transitions);
}
// Auto-leave after prolonged host silence. We keep polling as long as
// state reads succeed (short reconnects are silent), but if the host
// hasn't written a fresh state blob for > HOST_TIMEOUT_MS we treat the
// session as effectively dead and surface the exit modal. Manual exit
// still works instantly — the bar's X button short-circuits this path.
if (state.positionAt > 0 && (Date.now() - state.positionAt) > HOST_TIMEOUT_MS) {
useOrbitStore.getState().setError('host-timeout');
return;
}
// Reconcile pending guest suggestions against the host's *playable*
// queue — NOT `state.queue`, which is the suggestion history (every
// submission lands there immediately, even under manual-approval mode
// where the host hasn't actually accepted the track yet).
// `state.playQueue` is the host's real upcoming queue, so a trackId
// appearing there (or as `currentTrack`) means the host has merged it.
if (useOrbitStore.getState().pendingSuggestions.length > 0) {
const landed = new Set<string>();
for (const q of (state.playQueue ?? [])) landed.add(q.trackId);
if (state.currentTrack) landed.add(state.currentTrack.trackId);
useOrbitStore.getState().reconcilePendingSuggestions(landed);
landed.forEach(forgetPendingSuggestion);
// Mitigate the outbox lost-update race: a suggestion the host hasn't
// recorded (absent from state.queue, where every *received* submission
// lands) past a grace window was likely wiped by a racing sweep-clear
// — re-send it (the host dedupes, so this is idempotent). Give up +
// toast on ones that never land so the row doesn't hang forever.
const stillPending = useOrbitStore.getState().pendingSuggestions;
if (stillPending.length > 0 && outboxPlaylistId) {
const recorded = new Set(state.queue.map(q => q.trackId));
const plan = planPendingResends(stillPending, recorded);
for (const trackId of plan.resend) {
void ensureTrackInOutbox(outboxPlaylistId, trackId).catch(() => {});
}
if (plan.giveUp.length > 0) {
useOrbitStore.getState().reconcilePendingSuggestions(new Set(plan.giveUp));
plan.giveUp.forEach(forgetPendingSuggestion);
showToast(i18n.t('orbit.toastSuggestLost'), 3500, 'error');
}
}
}
// Host signalled session end: surface via `phase`, let the UI handle
// the modal. Outbox cleanup still happens via leaveOrbitSession().
if (state.ended) {
useOrbitStore.getState().setPhase('ended');
return;
}
// Kicked / soft-removed: transition into the error phase with a
// matching errorMessage so the UI can pick the right copy.
const me = useAuthStore.getState().getActiveServer()?.username;
if (me && state.kicked.includes(me)) {
useOrbitStore.getState().setError('kicked');
return;
}
// Soft-remove: only react to markers strictly newer than our own join
// time, otherwise a stale marker from a prior session-life would
// immediately bounce us out on rejoin.
if (me && state.removed && state.removed.length > 0) {
const joinedAt = useOrbitStore.getState().joinedAt ?? 0;
const hit = state.removed.find(r => r.user === me && r.at > joinedAt);
if (hit) {
useOrbitStore.getState().setError('removed');
return;
}
}
// ── Auto-sync host playback into local player ──
// Rules:
// 1. First tick after activation → mirror host (initial join sync,
// no need for the guest to click catch-up to get started).
// 2. Track changed at host → guest follows ONLY if they haven't
// locally diverged. A guest who hit pause should stay paused
// even when the host moves to the next song; otherwise their
// pause button silently un-does itself. If diverged, we just
// advance the anchor so Catch Up stays the opt-in path.
// 3. Same track, host flipped play/pause → mirror only if the local
// player still matches our last-applied host state. If the guest
// paused/resumed locally, we leave them alone — they have to
// click catch-up to opt back in.
const player = usePlayerStore.getState();
const hostTrackId = state.currentTrack?.trackId ?? null;
const hostPlaying = state.isPlaying;
const last = lastAppliedRef.current;
pushOrbitEvent('pull', JSON.stringify({
host: { track: hostTrackId, playing: hostPlaying, posMs: state.positionMs, posAt: state.positionAt },
guest: { track: player.currentTrack?.id ?? null, playing: player.isPlaying, posSec: Math.round(player.currentTime ?? 0) },
last,
}));
// Engine-recovery: detect a silent `audio_play` failure after our
// optimistic `isPlaying: true` mark. `playTrack` flips the store
// flag synchronously before the Tauri call resolves, so the
// post-playTrack poll sees `isPlaying === true` even when the
// engine never actually started; if `audio_play` later rejects,
// the catch handler sets `isPlaying: false`. Without this check
// the divergence-detection branches all pass (last/host both
// think we're playing) and the guest stays stuck silent. Reset
// `lastAppliedRef` so the next iteration re-runs initial-sync.
if (
last
&& last.isPlaying
&& !player.isPlaying
&& hostPlaying
&& last.trackId === hostTrackId
&& last.trackId === player.currentTrack?.id
) {
pushOrbitEvent('engine-recovery', 'engine fell back to paused while host plays — re-syncing');
lastAppliedRef.current = null;
}
// Re-read after the recovery check above may have reset it.
const currentLast = lastAppliedRef.current;
if (!currentLast) {
// Initial sync: only record `last` *after* syncToHost actually
// landed. If the first attempt loses the race (engine not ready,
// stale audio state, network blip), a retry ticker below will try
// again every 500 ms until it succeeds. Without this, the first
// failed sync set `last` anyway and the guest was stuck on their
// pre-join state until they clicked Catch Up.
if (hostTrackId) {
pushOrbitEvent('initial-sync', `attempting initial sync to ${hostTrackId} (hostPlaying=${hostPlaying})`);
const ok = await syncToHost(hostTrackId, state);
pushOrbitEvent('initial-sync', `result: ${ok ? 'success' : 'failed (will retry)'}`);
if (ok) lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
} else {
pushOrbitEvent('initial-sync', 'host has no current track yet, anchor only');
lastAppliedRef.current = { trackId: null, isPlaying: hostPlaying };
}
} else if (currentLast.trackId !== hostTrackId) {
// Distinguish "user manually paused" (true divergence) from "track
// ended naturally" (NOT divergence — guest just needs the host's
// next track loaded). Both leave `player.isPlaying === false`, but
// `handleAudioEnded` keeps `currentTrack` pinned to the just-ended
// track and resets `currentTime` to 0; a manual pause leaves
// `currentTime` somewhere mid-track. The 0-position discriminator
// separates them.
const naturalEnd = !player.isPlaying
&& player.currentTrack?.id === currentLast.trackId
&& (player.currentTime ?? 0) < 0.5;
const diverged = !naturalEnd && player.isPlaying !== currentLast.isPlaying;
if (diverged) {
// Guest is running their own show (typically: paused while host
// kept going). Do not load/start the host's new track — just
// track the host state so the catch-up prompt stays accurate.
pushOrbitEvent('track-change',
`host: ${currentLast.trackId}${hostTrackId} BUT guest diverged (player.isPlaying=${player.isPlaying} ≠ last.isPlaying=${currentLast.isPlaying}) — NOT loading new track`);
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
} else if (hostTrackId) {
pushOrbitEvent('track-change', `host: ${currentLast.trackId}${hostTrackId}, guest in sync, following`);
void syncToHost(hostTrackId, state);
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
} else {
pushOrbitEvent('track-change', `host cleared current track, pausing guest`);
if (player.isPlaying) player.pause();
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
}
} else if (currentLast.isPlaying !== hostPlaying) {
// Only mirror when the guest hasn't diverged. We compare against the
// *last applied* host state, not the new one — divergence means the
// local player no longer matches what we last pushed in.
const localMatchesLast = player.isPlaying === currentLast.isPlaying;
pushOrbitEvent('play-pause-flip',
`host: ${currentLast.isPlaying}${hostPlaying}, guest matches last=${localMatchesLast} (will ${localMatchesLast ? 'mirror' : 'skip'})`);
if (localMatchesLast) {
if (hostPlaying) player.resume();
else player.pause();
}
// Either way, advance the anchor so we don't keep retrying the same
// flip every tick.
lastAppliedRef.current = { trackId: currentLast.trackId, isPlaying: hostPlaying };
}
};
// Self-scheduling tick: fast-poll (500 ms) while we haven't locked in an
// initial sync yet, fall back to the steady cadence once we're anchored.
// Lets a failed first attempt retry quickly without spamming the network
// for the lifetime of the session.
let timer: number | null = null;
const tick = async () => {
timer = null;
await pull();
if (cancelled) return;
const delay = lastAppliedRef.current === null ? 500 : STATE_READ_TICK_MS;
timer = window.setTimeout(tick, delay);
};
void tick();
return () => {
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
// Leaving / session ended → give the user their own transition prefs back.
restoreGuestTransitions();
resetPendingResendState();
};
// outboxPlaylistId is read inside the tick loop at call time; the loop is
// intentionally (re)started only on session activation / playlist change, not
// when the outbox id updates mid-session.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, sessionPlaylistId]);
// Outbox heartbeat — shared with the host hook; the guest's outbox is keyed
// by its own active-server username.
useOrbitOutboxHeartbeat(active, outboxPlaylistId, sessionId, myName);
}
+261
View File
@@ -0,0 +1,261 @@
import { getSong } from '@/api/subsonicLibrary';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useEffect } from 'react';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { usePlayerStore } from '@/store/playerStore';
import {
writeOrbitState,
sweepGuestOutboxes,
applyOutboxSnapshotsToState,
maybeShuffleQueue,
effectiveShuffleIntervalMs,
makeCoalescedRunner,
readOrbitTransitionSettings,
suggestionKey,
} from '@/features/orbit/utils/orbit';
import {
ORBIT_DEFAULT_SETTINGS,
ORBIT_PLAY_QUEUE_LIMIT,
type OrbitState,
type OrbitQueueItem,
} from '@/features/orbit/api/orbit';
import { showToast } from '@/utils/ui/toast';
import i18n from '@/i18n';
import { pushOrbitEvent } from '@/features/orbit/utils/orbitDiag';
import { useOrbitOutboxHeartbeat } from '@/features/orbit/hooks/useOrbitOutboxHeartbeat';
/**
* Orbit — host-side tick hook.
*
* Mounted once at the app shell level; only does work when the local store
* says we're the host of an active session. Two independent timers:
*
* - **State tick** (2.5 s): snapshot isPlaying + position + current track
* from the player store, patch the local OrbitState, push to the
* session playlist's comment.
* - **Heartbeat tick** (10 s): refresh the host's own outbox playlist's
* comment with a fresh timestamp so the later-added participant
* pipeline can treat the host symmetrically.
*
* Writes are best-effort — a transient Navidrome outage just means guests
* see stale state for a tick or two and catch up on the next write.
* Phase 2 does not yet consume anything from guests.
*/
const STATE_TICK_MS = 2_500;
export function useOrbitHost(): void {
const role = useOrbitStore(s => s.role);
const phase = useOrbitStore(s => s.phase);
const sessionPlaylistId = useOrbitStore(s => s.sessionPlaylistId);
const outboxPlaylistId = useOrbitStore(s => s.outboxPlaylistId);
const sessionId = useOrbitStore(s => s.sessionId);
const hostName = useOrbitStore(s => s.state?.host);
const active = role === 'host' && phase === 'active' && !!sessionPlaylistId;
useEffect(() => {
if (!active || !sessionPlaylistId) return;
const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => {
const p = usePlayerStore.getState();
const now = Date.now();
return {
isPlaying: p.isPlaying,
positionMs: Math.round((p.currentTime ?? 0) * 1000),
positionAt: now,
currentTrack: p.currentTrack
? {
trackId: p.currentTrack.id,
// Locally-initiated plays are marked as authored by the host.
// Guest-suggested tracks that later become `currentTrack` will
// carry their original attribution because the queue-consume
// flow keeps the `addedBy` from the guest's outbox.
addedBy: hostUsername,
addedAt: now,
}
: null,
};
};
const pushState = async () => {
const store = useOrbitStore.getState();
const base = store.state;
if (!base) return;
// 1) Sweep every guest outbox: new suggestions + fresh heartbeats.
let afterSweep = base;
try {
const snaps = await sweepGuestOutboxes(base.sid, base.host);
afterSweep = applyOutboxSnapshotsToState(base, snaps);
} catch { /* best-effort; keep old participants and queue */ }
// 2) Merge newly-suggested items into the host's local play queue so
// guest suggestions actually start playing alongside host-chosen
// tracks. Must happen BEFORE the shuffle step so the merge decision
// tracks `addedAt` (immutable) rather than list position.
await mergeNewSuggestionsIntoQueue(afterSweep.queue);
// 3) Shuffle check:
// a) `maybeShuffleQueue` handles the OrbitState.queue (guest-facing
// suggestion list). It also bumps `lastShuffle` even when the list
// is too small to reorder — that's the authoritative 15-min marker.
// b) In parallel, we shuffle the *host's* upcoming play queue so the
// mix the guests hear actually changes. `autoShuffle=false` skips
// both.
const shouldShuffleNow = afterSweep.settings?.autoShuffle !== false
&& (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep));
const afterShuffle = maybeShuffleQueue(afterSweep);
if (shouldShuffleNow) {
const before = usePlayerStore.getState().queueItems.length;
usePlayerStore.getState().shuffleUpcomingQueue();
if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info');
}
// 4) Overlay the host's live playback snapshot. Host tick reads ids only,
// so the thin `queueItems` refs are all we need (no full Track resolve).
const playerLive = usePlayerStore.getState();
const upcoming = playerLive.queueItems.slice(playerLive.queueIndex + 1);
// Map track id → original suggester (if any). State's `queue` carries
// every suggestion we've ever seen this session, so it's the right
// attribution source even after the track has been merged into the
// host's player queue.
const suggesterByTrack = new Map<string, string>();
for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy);
const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(r => ({
trackId: r.trackId,
addedBy: suggesterByTrack.get(r.trackId) ?? base.host,
}));
const next: OrbitState = {
...afterShuffle,
...snapshotPlayerPatch(base.host),
playQueue,
playQueueTotal: upcoming.length,
// Refresh the mirrored transition prefs each tick so a mid-session
// change to the host's crossfade/gapless/AutoDJ reaches guests.
settings: { ...(afterShuffle.settings ?? ORBIT_DEFAULT_SETTINGS), transitions: readOrbitTransitionSettings() },
};
// 5) Commit locally + push remote.
useOrbitStore.getState().setState(next);
try {
await writeOrbitState(sessionPlaylistId, next);
pushOrbitEvent('host:push', JSON.stringify({
track: next.currentTrack?.trackId ?? null,
playing: next.isPlaying,
posMs: next.positionMs,
queueLen: next.playQueueTotal ?? next.playQueue?.length ?? 0,
guests: next.participants.length,
}));
} catch (e) {
pushOrbitEvent('host:push', `WRITE FAILED: ${String(e)}`);
/* best-effort; next tick retries */
}
};
/**
* Resolve each not-yet-merged suggestion via `getSong` and append to the
* player queue. Records a toast per successful append so the host notices
* guest activity. Safe to call every tick — the set filter keeps it idempotent.
*/
const mergeNewSuggestionsIntoQueue = async (items: readonly OrbitQueueItem[]) => {
// Opt-out: host turned auto-approve off. Items still accumulate in
// `OrbitState.queue` and show up in the guest view / approval list —
// they just don't flow into the host's actual play queue yet.
const store = useOrbitStore.getState();
const settings = store.state?.settings;
if (settings && settings.autoApprove === false) return;
// Host-authored items are enqueued directly by `hostEnqueueToOrbit` and
// must not flow through the merge pipeline again — otherwise the tick
// would duplicate the track into the upcoming queue. Declined items
// stay out too; merged items are the existing dedup anchor.
const hostUser = store.state?.host;
const mergedKeys = new Set(store.mergedSuggestionKeys);
const declinedKeys = new Set(store.declinedSuggestionKeys);
const pending = items.filter(q =>
q.addedBy !== hostUser
&& !mergedKeys.has(suggestionKey(q))
&& !declinedKeys.has(suggestionKey(q))
);
if (pending.length === 0) return;
// Resolve in parallel — Navidrome is fine with concurrent getSong calls.
const resolved = await Promise.all(pending.map(async q => {
try {
const song = await getSong(q.trackId);
return song ? { q, track: songToTrack(song) } : null;
} catch {
return null;
}
}));
const toEnqueue = resolved.filter((r): r is { q: OrbitQueueItem; track: ReturnType<typeof songToTrack> } => r !== null);
const markAllAsMerged = () => pending.forEach(q => store.addMergedSuggestion(suggestionKey(q)));
if (toEnqueue.length === 0) {
// Mark the failed lookups as seen anyway so we don't keep retrying
// every tick for a track the server can't serve.
markAllAsMerged();
return;
}
// Sprinkle each track at a random spot inside the upcoming range so
// guest suggestions interleave with host-picked tracks rather than
// pile up at the end (where they'd never play until the 15-min shuffle).
const player = usePlayerStore.getState();
for (const { track } of toEnqueue) {
const live = usePlayerStore.getState();
const from = Math.max(0, live.queueIndex + 1);
const to = live.queueItems.length;
const span = Math.max(1, to - from + 1);
const pos = from + Math.floor(Math.random() * span);
player.enqueueAt([track], pos);
}
markAllAsMerged();
// Friendly nudge per sweep, not per track — bundled toast if >1.
if (toEnqueue.length === 1) {
const { q, track } = toEnqueue[0];
showToast(i18n.t('orbit.toastSuggested', { user: q.addedBy, title: track.title }), 3000, 'info');
} else {
showToast(i18n.t('orbit.toastSuggestedMany', { count: toEnqueue.length }), 3000, 'info');
}
};
// Serialise pushState across its three triggers (mount, timer, play/pause
// flip): two bodies must never run concurrently, or a slow run that already
// swept+cleared an outbox can lose its write to a faster one. A request
// arriving mid-run coalesces into a single rerun so no trigger is lost.
const runPush = makeCoalescedRunner(pushState);
// Immediate push on mount so guests see fresh state without waiting
// a full tick after the host comes online.
void runPush();
const id = window.setInterval(() => { void runPush(); }, STATE_TICK_MS);
// Event-driven push on play/pause flips. Without this the worst-case
// delay between "host hits pause" and "guest stops" is two full polling
// windows (host tick + guest tick = up to 5 s) — long enough for the
// guest to noticeably run ahead. Subscribing here adds at most one
// extra write per flip; non-flip state ticks still ride the 2.5 s
// timer. Listener filters on isPlaying so currentTime ticks don't
// trigger spurious pushes.
let prevIsPlaying = usePlayerStore.getState().isPlaying;
const unsubPlayPause = usePlayerStore.subscribe((state) => {
if (state.isPlaying === prevIsPlaying) return;
prevIsPlaying = state.isPlaying;
pushOrbitEvent('host:event-push', `isPlaying flip → ${state.isPlaying}`);
void runPush();
});
return () => {
window.clearInterval(id);
unsubPlayPause();
};
}, [active, sessionPlaylistId]);
// Outbox heartbeat — shared with the guest hook; the host's outbox is keyed
// by its own `OrbitState.host` name.
useOrbitOutboxHeartbeat(active, outboxPlaylistId, sessionId, hostName);
}
@@ -0,0 +1,38 @@
import { useEffect } from 'react';
import { writeOrbitHeartbeat } from '@/features/orbit/utils/orbit';
import { orbitOutboxPlaylistName } from '@/features/orbit/api/orbit';
const HEARTBEAT_TICK_MS = 10_000;
/**
* Shared Orbit outbox heartbeat — used by both the host and guest tick hooks.
*
* Refreshes the caller's own outbox playlist comment with a fresh timestamp
* every 10 s so the host's participant sweep sees the user as alive (and so
* the host's own outbox is refreshed symmetrically). Host and guest differ
* only in whose name owns the outbox: `ownName` is `OrbitState.host` for the
* host and the active server username for a guest.
*
* Best-effort — a transient Navidrome outage just skips a beat; the next
* interval tick retries.
*/
export function useOrbitOutboxHeartbeat(
active: boolean,
outboxPlaylistId: string | null,
sessionId: string | null,
ownName: string | null | undefined,
): void {
useEffect(() => {
if (!active || !outboxPlaylistId || !sessionId || !ownName) return;
const outboxName = orbitOutboxPlaylistName(sessionId, ownName);
const beat = async () => {
try { await writeOrbitHeartbeat(outboxPlaylistId, outboxName); }
catch { /* best-effort */ }
};
void beat();
const id = window.setInterval(() => { void beat(); }, HEARTBEAT_TICK_MS);
return () => window.clearInterval(id);
}, [active, outboxPlaylistId, sessionId, ownName]);
}
@@ -0,0 +1,73 @@
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import {
suggestOrbitTrack,
hostEnqueueToOrbit,
evaluateOrbitSuggestGate,
OrbitSuggestBlockedError,
} from '@/features/orbit/utils/orbit';
import { showToast } from '@/utils/ui/toast';
/**
* Shared behaviour for song rows that in "normal mode" swallow a full list
* into the queue on single-click (AlbumDetail, PlaylistDetail, Favorites,
* ArtistDetail top-songs, SearchBrowsePage, RandomMix).
*
* In an active Orbit session this is too destructive — the list would
* propagate to every guest's player. Instead:
*
* - `queueHint()` — show a toast telling the user to double-click.
* Safe to call on every single-click; 220 ms debounce
* suppresses the pileup that browsers emit before a
* dblclick fires.
* - `addTrackToOrbit(songId)` — cancel any pending hint and add just that
* one track: suggestOrbitTrack for guests,
* hostEnqueueToOrbit for the host.
*
* `orbitActive` is the gate — when false, callers should skip the hint and
* run their original bulk-play path unchanged.
*/
export function useOrbitSongRowBehavior() {
const { t } = useTranslation();
const orbitRole = useOrbitStore(s => s.role);
const orbitActive = orbitRole === 'host' || orbitRole === 'guest';
const clickTimerRef = useRef<number | null>(null);
const queueHint = useCallback(() => {
if (clickTimerRef.current !== null) return;
clickTimerRef.current = window.setTimeout(() => {
clickTimerRef.current = null;
showToast(t('albumDetail.orbitDoubleClickHint'), 2400, 'info');
}, 220);
}, [t]);
const addTrackToOrbit = useCallback((songId: string) => {
if (clickTimerRef.current !== null) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
if (orbitRole === 'guest') {
const gate = evaluateOrbitSuggestGate();
if (!gate.allowed && gate.reason === 'muted') {
showToast(t('orbit.suggestBlockedMuted'), 3500, 'error');
return;
}
suggestOrbitTrack(songId)
.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');
}
});
} else if (orbitRole === 'host') {
hostEnqueueToOrbit(songId)
.then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info'))
.catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error'));
}
}, [orbitRole, t]);
return { orbitActive, queueHint, addTrackToOrbit };
}
@@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { usePlaybackRateStore } from '@/store/playbackRateStore';
/** Re-sync playback rate when Orbit enters or leaves shared playback. */
export function usePlaybackRateOrbitSync() {
const role = useOrbitStore(s => s.role);
const phase = useOrbitStore(s => s.phase);
useEffect(() => {
usePlaybackRateStore.getState().syncToRust();
}, [role, phase]);
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Orbit feature — multi-user listen-together: the shared-session state types
* and Navidrome-playlist transport (`api/orbit`), host/guest lifecycle +
* moderation + outbox sweep + drift math (`utils/*`), the orbit stores, the
* session/account hooks, and all Orbit UI (session bar, modals, popovers,
* guest queue, wordmark). Replaces the former `utils/orbit.ts` re-export shim.
*
* Playback-core (`playTrackAction`, `nextAction`, `queueMutationActions`,
* `resumeAction`, `playbackRateStore`, `playbackReportSession`, `previewStore`)
* drives orbit and so consumes this barrel — the correct playback→orbit edge,
* realized early (playback moves last in M3). `nextActionOrbitRadio.test.ts`
* stays out: it tests `nextAction` (playback-core), not orbit.
*/
export * from './api/orbit';
export * from './hooks/useOrbitBodyAttrs';
export * from './hooks/useOrbitGuest';
export * from './hooks/useOrbitHost';
export * from './hooks/useOrbitOutboxHeartbeat';
export * from './hooks/useOrbitSongRowBehavior';
export * from './hooks/usePlaybackRateOrbitSync';
export * from './store/orbitAccountPickerStore';
export * from './store/orbitSession';
export * from './store/orbitStore';
export * from './utils/cleanup';
export * from './utils/constants';
export * from './utils/guest';
export * from './utils/helpers';
export * from './utils/host';
export * from './utils/moderation';
export * from './utils/orbitBulkGuard';
export * from './utils/orbitDiag';
export * from './utils/orbitNames';
export * from './utils/pendingResend';
export * from './utils/remote';
export * from './utils/sessionActive';
export * from './utils/shareLink';
export * from './utils/stateMath';
export * from './utils/sweep';
export * from './utils/transitions';
export { default as OrbitAccountPicker } from './components/OrbitAccountPicker';
export { default as OrbitDiagnosticsPopover } from './components/OrbitDiagnosticsPopover';
export { default as OrbitExitModal } from './components/OrbitExitModal';
export { default as OrbitGuestQueue } from './components/OrbitGuestQueue';
export { default as OrbitHelpModal } from './components/OrbitHelpModal';
export { default as OrbitJoinModal } from './components/OrbitJoinModal';
export { default as OrbitParticipantsPopover } from './components/OrbitParticipantsPopover';
export { default as OrbitQueueHead } from './components/OrbitQueueHead';
export { default as OrbitSessionBar } from './components/OrbitSessionBar';
export { default as OrbitSettingsPopover } from './components/OrbitSettingsPopover';
export { default as OrbitSharePopover } from './components/OrbitSharePopover';
export { default as OrbitStartModal } from './components/OrbitStartModal';
export { default as OrbitStartTrigger } from './components/OrbitStartTrigger';
export { default as OrbitWordmark } from './components/OrbitWordmark';
@@ -0,0 +1,38 @@
import type { ServerProfile } from '@/store/authStoreTypes';
import { create } from 'zustand';
let _resolve: ((server: ServerProfile | null) => void) | null = null;
interface OrbitAccountPickerStore {
isOpen: boolean;
accounts: ServerProfile[];
/** Open the picker with the given candidates. Resolves with the chosen
* server or null if the user cancels. */
request: (accounts: ServerProfile[]) => Promise<ServerProfile | null>;
pick: (server: ServerProfile) => void;
cancel: () => void;
}
export const useOrbitAccountPickerStore = create<OrbitAccountPickerStore>(set => ({
isOpen: false,
accounts: [],
request: (accounts) =>
new Promise<ServerProfile | null>(resolve => {
// If another picker is already pending, treat the previous one as cancelled.
if (_resolve) _resolve(null);
_resolve = resolve;
set({ isOpen: true, accounts });
}),
pick: (server) => {
_resolve?.(server);
_resolve = null;
set({ isOpen: false });
},
cancel: () => {
_resolve?.(null);
_resolve = null;
set({ isOpen: false });
},
}));
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { orbitState } = vi.hoisted(() => ({
orbitState: {
role: null as 'host' | 'guest' | null,
phase: 'idle' as 'idle' | 'starting' | 'joining' | 'active' | 'ending' | 'ended' | 'error',
},
}));
vi.mock('@/features/orbit/store/orbitStore', () => ({
useOrbitStore: { getState: () => orbitState },
}));
import { isInOrbitSession } from '@/features/orbit/store/orbitSession';
beforeEach(() => {
orbitState.role = null;
orbitState.phase = 'idle';
});
describe('isInOrbitSession', () => {
it('returns false when role is null', () => {
expect(isInOrbitSession()).toBe(false);
});
it.each(['active', 'joining', 'starting'] as const)(
"returns true for role='host', phase='%s'",
phase => {
orbitState.role = 'host';
orbitState.phase = phase;
expect(isInOrbitSession()).toBe(true);
},
);
it.each(['active', 'joining', 'starting'] as const)(
"returns true for role='guest', phase='%s'",
phase => {
orbitState.role = 'guest';
orbitState.phase = phase;
expect(isInOrbitSession()).toBe(true);
},
);
it.each(['idle', 'ending', 'ended', 'error'] as const)(
"returns false for transient/inactive phase='%s'",
phase => {
orbitState.role = 'host';
orbitState.phase = phase;
expect(isInOrbitSession()).toBe(false);
},
);
});
+17
View File
@@ -0,0 +1,17 @@
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
/**
* True when the user is part of an Orbit session (any role, any phase short
* of `idle` / `error` / `ended`). Used by `next()` and its async fallback
* callbacks to suppress local queue-extension paths (radio top-up, infinite
* queue, queue-exhausted refill) — those would either pop the
* `orbitBulkGuard` modal or silently inject tracks the host didn't pick.
* Also called inside in-flight `.then()` callbacks so a fetch scheduled
* just before the user joined Orbit doesn't fire a `playTrack` after the
* join.
*/
export function isInOrbitSession(): boolean {
const o = useOrbitStore.getState();
if (o.role !== 'host' && o.role !== 'guest') return false;
return o.phase === 'active' || o.phase === 'joining' || o.phase === 'starting';
}
+147
View File
@@ -0,0 +1,147 @@
import { create } from 'zustand';
import type { OrbitState } from '@/features/orbit/api/orbit';
/**
* Orbit — local session store.
*
* Mirrors the remote canonical state for the UI, plus a handful of
* client-only fields (our role in the session, the playlist ids we're
* bound to, lifecycle phase). Not persisted — a session is transient by
* design; if the app restarts mid-session, we re-join on next open
* rather than resurrect stale local state.
*
* Phase 1 is intentionally thin: only `set`/`reset` plumbing so later
* phases (host/guest lifecycle, track pipeline) can drop into place
* without touching the store shape.
*/
export type OrbitRole = 'host' | 'guest';
/** Fine-grained lifecycle phase. Drives which modal/indicator UI is visible. */
export type OrbitPhase =
/** No session bound. */
| 'idle'
/** Host: creating the session playlist and seeding state. */
| 'starting'
/** Guest: auth + lookup before commit. */
| 'joining'
/** Session established; polling cycle active. */
| 'active'
/** Host ended the session; showing exit modal. */
| 'ended'
/** Unrecoverable error (server unreachable, playlist vanished, etc.). */
| 'error';
interface OrbitStore {
/** Current role in the session, or null when idle. */
role: OrbitRole | null;
/** Active session id, or null. */
sessionId: string | null;
/** Navidrome playlist id of the canonical session playlist. */
sessionPlaylistId: string | null;
/** Navidrome playlist id of our own outbox (exists for both host and guest). */
outboxPlaylistId: string | null;
/** Lifecycle phase. */
phase: OrbitPhase;
/** Latest-known canonical state (last poll). Null while starting/joining. */
state: OrbitState | null;
/** Human-readable error when `phase === 'error'`. */
errorMessage: string | null;
/**
* Wall-clock ms when this client joined the current session (host: start
* time, guest: join time). Used to disambiguate stale `removed`-list
* entries from a fresh re-join after a remove. Null when idle.
*/
joinedAt: number | null;
/**
* Guest-only: track ids the local client has suggested but the host
* hasn't yet merged into the shared queue. Filled by
* `suggestOrbitTrack`, drained by the guest tick once the id appears
* in `state.queue` / `state.currentTrack`. In-memory only — a rejoin
* starts empty, any still-pending ids either land or get dropped by
* the host's next sweep anyway.
*/
pendingSuggestions: string[];
/**
* Host-only: suggestionKeys (see suggestionKey() in utils/orbit) that
* the host has already merged into the play queue — whether via
* auto-approve or an explicit Approve button. Stops the host tick
* from re-inserting the same item on every sweep.
*/
mergedSuggestionKeys: string[];
/**
* Host-only: suggestionKeys that the host explicitly declined. Keeps
* them out of the merge pipeline AND out of the pending-approvals UI
* so a declined suggestion doesn't keep begging for attention.
*/
declinedSuggestionKeys: string[];
// ── Setters (Phase 1 scaffolding; later phases add real actions) ────────
setPhase: (phase: OrbitPhase) => void;
setRole: (role: OrbitRole | null) => void;
setSessionBinding: (args: {
sessionId: string | null;
sessionPlaylistId: string | null;
outboxPlaylistId: string | null;
}) => void;
setState: (state: OrbitState | null) => void;
setError: (message: string | null) => void;
addPendingSuggestion: (trackId: string) => void;
/** Keep only the pending ids that are NOT yet observable in the shared queue. */
reconcilePendingSuggestions: (landedTrackIds: Set<string>) => void;
/** Host: mark a suggestion as merged so the tick stops re-proposing it. */
addMergedSuggestion: (key: string) => void;
/** Host: mark a suggestion as declined so the approval UI and tick ignore it. */
addDeclinedSuggestion: (key: string) => void;
/** Tear down the session locally. Does NOT clean up remote playlists. */
reset: () => void;
}
const initialState = {
role: null,
sessionId: null,
sessionPlaylistId: null,
outboxPlaylistId: null,
phase: 'idle' as OrbitPhase,
state: null,
errorMessage: null,
joinedAt: null,
pendingSuggestions: [] as string[],
mergedSuggestionKeys: [] as string[],
declinedSuggestionKeys: [] as string[],
} satisfies Omit<OrbitStore,
| 'setPhase' | 'setRole' | 'setSessionBinding' | 'setState' | 'setError'
| 'addPendingSuggestion' | 'reconcilePendingSuggestions'
| 'addMergedSuggestion' | 'addDeclinedSuggestion' | 'reset'
>;
export const useOrbitStore = create<OrbitStore>()((set) => ({
...initialState,
setPhase: (phase) => set({ phase }),
setRole: (role) => set({ role }),
setSessionBinding: ({ sessionId, sessionPlaylistId, outboxPlaylistId }) =>
set({ sessionId, sessionPlaylistId, outboxPlaylistId }),
setState: (state) => set({ state }),
setError: (message) => set({ phase: message ? 'error' : 'idle', errorMessage: message }),
addPendingSuggestion: (trackId) => set(s => (
s.pendingSuggestions.includes(trackId)
? s
: { pendingSuggestions: [...s.pendingSuggestions, trackId] }
)),
reconcilePendingSuggestions: (landedTrackIds) => set(s => {
const next = s.pendingSuggestions.filter(id => !landedTrackIds.has(id));
return next.length === s.pendingSuggestions.length ? s : { pendingSuggestions: next };
}),
addMergedSuggestion: (key) => set(s => (
s.mergedSuggestionKeys.includes(key)
? s
: { mergedSuggestionKeys: [...s.mergedSuggestionKeys, key] }
)),
addDeclinedSuggestion: (key) => set(s => (
s.declinedSuggestionKeys.includes(key)
? s
: { declinedSuggestionKeys: [...s.declinedSuggestionKeys, key] }
)),
reset: () => set({ ...initialState }),
}));
+156
View File
@@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ORBIT_PLAYLIST_PREFIX,
makeInitialOrbitState,
orbitOutboxPlaylistName,
orbitSessionPlaylistName,
type OrbitState,
} from '@/features/orbit/api/orbit';
import { ORBIT_ORPHAN_TTL_MS } from '@/features/orbit/utils/constants';
const { getPlaylists, deletePlaylist } = vi.hoisted(() => ({
getPlaylists: vi.fn(),
deletePlaylist: vi.fn(),
}));
const { authState, orbitState } = vi.hoisted(() => ({
authState: { username: 'me' as string | undefined },
orbitState: { sessionId: null as string | null },
}));
vi.mock('@/api/subsonicPlaylists', () => ({ getPlaylists, deletePlaylist }));
vi.mock('@/store/authStore', () => ({
useAuthStore: {
getState: () => ({
getActiveServer: () => (authState.username ? { username: authState.username } : undefined),
}),
},
}));
vi.mock('@/features/orbit/store/orbitStore', () => ({
useOrbitStore: { getState: () => ({ sessionId: orbitState.sessionId }) },
}));
import { cleanupOrphanedOrbitPlaylists } from '@/features/orbit/utils/cleanup';
type FakePlaylist = {
id: string;
name: string;
owner?: string;
comment?: string;
changed?: string;
};
/** A session-playlist comment whose heartbeat (`positionAt`) is `ageMs` old. */
function sessionComment(sid: string, ageMs: number, ended = false): string {
const state: OrbitState = makeInitialOrbitState({ sid, host: 'me', name: 'sesh' });
state.positionAt = Date.now() - ageMs;
if (ended) state.ended = true;
return JSON.stringify(state);
}
/** An outbox comment whose heartbeat `ts` is `ageMs` old. */
function outboxComment(ageMs: number): string {
return JSON.stringify({ ts: Date.now() - ageMs });
}
beforeEach(() => {
authState.username = 'me';
orbitState.sessionId = null;
deletePlaylist.mockReset().mockResolvedValue(undefined);
});
afterEach(() => {
getPlaylists.mockReset();
});
async function runWith(playlists: FakePlaylist[]): Promise<string[]> {
getPlaylists.mockResolvedValue(playlists);
await cleanupOrphanedOrbitPlaylists();
return deletePlaylist.mock.calls.map(c => c[0] as string);
}
describe('cleanupOrphanedOrbitPlaylists', () => {
it('keeps a fresh session playlist from another device (regression for the regex bug)', async () => {
const sid = 'aaaa1111';
const deleted = await runWith([
{ id: 'p1', name: orbitSessionPlaylistName(sid), owner: 'me', comment: sessionComment(sid, 1_000) },
]);
expect(deleted).toEqual([]);
});
it('deletes a stale session playlist past the orphan TTL', async () => {
const sid = 'bbbb2222';
const deleted = await runWith([
{
id: 'p2',
name: orbitSessionPlaylistName(sid),
owner: 'me',
comment: sessionComment(sid, ORBIT_ORPHAN_TTL_MS + 60_000),
},
]);
expect(deleted).toEqual(['p2']);
});
it('deletes a session the host explicitly ended even when fresh', async () => {
const sid = 'cccc3333';
const deleted = await runWith([
{
id: 'p3',
name: orbitSessionPlaylistName(sid),
owner: 'me',
comment: sessionComment(sid, 1_000, /* ended */ true),
},
]);
expect(deleted).toEqual(['p3']);
});
it('never touches this device\'s current session', async () => {
const sid = 'dddd4444';
orbitState.sessionId = sid;
const deleted = await runWith([
// Stale heartbeat, but it's *our* live session → must be skipped.
{
id: 'p4',
name: orbitSessionPlaylistName(sid),
owner: 'me',
comment: sessionComment(sid, ORBIT_ORPHAN_TTL_MS + 60_000),
},
]);
expect(deleted).toEqual([]);
});
it('keeps a fresh outbox but prunes a stale one', async () => {
const sid = 'eeee5555';
const deleted = await runWith([
{ id: 'fresh', name: orbitOutboxPlaylistName(sid, 'bob'), owner: 'me', comment: outboxComment(1_000) },
{
id: 'stale',
name: orbitOutboxPlaylistName(sid, 'eve'),
owner: 'me',
comment: outboxComment(ORBIT_ORPHAN_TTL_MS + 60_000),
},
]);
expect(deleted).toEqual(['stale']);
});
it('prunes an unrecognisable __psyorbit_* playlist', async () => {
const deleted = await runWith([
{ id: 'junk', name: `${ORBIT_PLAYLIST_PREFIX}not-a-real-name`, owner: 'me' },
]);
expect(deleted).toEqual(['junk']);
});
it('ignores playlists owned by another user', async () => {
const sid = 'ffff6666';
const deleted = await runWith([
{
id: 'foreign',
name: orbitSessionPlaylistName(sid),
owner: 'someone-else',
comment: sessionComment(sid, ORBIT_ORPHAN_TTL_MS + 60_000),
},
]);
expect(deleted).toEqual([]);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { deletePlaylist, getPlaylists } from '@/api/subsonicPlaylists';
import { useAuthStore } from '@/store/authStore';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { ORBIT_PLAYLIST_PREFIX, parseOrbitState } from '@/features/orbit/api/orbit';
import { ORBIT_ORPHAN_TTL_MS } from '@/features/orbit/utils/constants';
/**
* App-start sweep: delete our own __psyorbit_* playlists that no longer
* belong to a live session. "Live" means either this device's current
* session (never touch) or one whose heartbeat is less than
* `ORBIT_ORPHAN_TTL_MS` old (could be a session on another device of
* ours). Anything older — including unparseable / comment-less entries —
* is a leftover from a crash / force-close / network blip and gets
* removed so it doesn't clutter the Navidrome playlist view.
*
* Runs best-effort; individual failures are swallowed. Returns the count
* of playlists actually deleted, for logging.
*/
export async function cleanupOrphanedOrbitPlaylists(): Promise<number> {
const username = useAuthStore.getState().getActiveServer()?.username;
if (!username) return 0;
const all = await getPlaylists(true).catch(() => [] as Awaited<ReturnType<typeof getPlaylists>>);
const now = Date.now();
const TTL = ORBIT_ORPHAN_TTL_MS;
const currentSid = useOrbitStore.getState().sessionId;
// The trailing `__` is part of *both* the session name (`__psyorbit_<sid>__`)
// and the outbox name (`__psyorbit_<sid>_from_<user>__`), so it must sit
// outside the optional `_from_…` group. Keeping it inside (the old bug) meant
// the bare session name never matched and fell into the unconditional-prune
// branch below — deleting live sessions on the user's other devices.
const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+)?__$`);
let deleted = 0;
for (const p of all) {
if (!p.name.startsWith(ORBIT_PLAYLIST_PREFIX)) continue;
// Only touch our own — Navidrome rejects deletes on foreign playlists anyway.
if (p.owner && p.owner !== username) continue;
const match = p.name.match(nameRe);
// Not one we recognise — assume corrupt, prune.
if (!match) {
try { await deletePlaylist(p.id); deleted++; } catch { /* best-effort */ }
continue;
}
const sid = match[1];
const isOutbox = !!match[2];
if (sid === currentSid) continue;
let timestamp = 0;
let ended = false;
if (p.comment) {
try {
const parsed = JSON.parse(p.comment);
if (isOutbox) {
if (parsed && typeof parsed.ts === 'number') timestamp = parsed.ts;
} else {
const state = parseOrbitState(parsed);
if (state) {
timestamp = state.positionAt ?? 0;
ended = state.ended === true;
}
}
} catch { /* unparseable → treat as dead */ }
}
// Fall back to Navidrome's `changed` timestamp when there's no
// orbit-authored heartbeat in the comment — saves us from deleting a
// playlist that was just created seconds ago.
if (timestamp === 0 && p.changed) {
const parsed = Date.parse(p.changed);
if (!isNaN(parsed)) timestamp = parsed;
}
const stale = timestamp === 0 || (now - timestamp > TTL);
if (ended || stale) {
try { await deletePlaylist(p.id); deleted++; } catch { /* best-effort */ }
}
}
return deleted;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Orbit cadence + TTL constants.
*
* Centralised so host/guest/state-math code reads from the same source of
* truth. Defaults are deliberately chosen against observed Navidrome poll
* cadences and real-world flake budgets.
*/
/** How long we consider a heartbeat still fresh. Longer than the guest tick so a single missed beat is tolerated. */
export const ORBIT_HEARTBEAT_ALIVE_MS = 30_000;
/**
* Grace window for the app-start orphan sweep. A session on the user's
* other device or a browser that briefly restarted must NOT be deleted
* by this sweep. 5 min matches the guest-side host-timeout threshold:
* if a session is silent for that long, it's fair to treat it as dead;
* anything shorter is a real restart and must survive.
*/
export const ORBIT_ORPHAN_TTL_MS = 5 * 60_000;
/**
* Legacy / fallback shuffle cadence. New sessions store their own interval
* in `OrbitState.settings.shuffleIntervalMin`; `effectiveShuffleIntervalMs`
* resolves that against this constant for sessions created before the
* field existed.
*/
export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000;
/**
* Upper bound on `OrbitState.queue` (the suggestion/attribution history).
* The list is append-only at the sweep-fold step, so without a cap a long
* session grows it without limit until the serialised blob blows past
* `ORBIT_STATE_MAX_BYTES` and the host silently stops publishing. We keep the
* most-recently-added entries (oldest dropped first) — those cover the
* upcoming play queue, which is all the attribution lookup needs. The wire
* serialiser trims further per-write if the blob still overflows.
*/
export const ORBIT_QUEUE_HISTORY_LIMIT = 64;
/**
* How long a soft-`removed` marker stays in the state blob. Long enough for
* the affected guest's 2.5 s read tick to surface the modal even after a
* one-tick miss; short enough that the marker doesn't bloat state if the
* guest never reconnects.
*/
export const ORBIT_REMOVED_TTL_MS = 60_000;
+228
View File
@@ -0,0 +1,228 @@
import { createPlaylist, deletePlaylist, getPlaylist, getPlaylists, updatePlaylist } from '@/api/subsonicPlaylists';
import { getSong } from '@/api/subsonicLibrary';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useAuthStore } from '@/store/authStore';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { usePlayerStore } from '@/store/playerStore';
import {
orbitOutboxPlaylistName,
type OrbitQueueItem,
type OrbitState,
} from '@/features/orbit/api/orbit';
import { suggestionKey } from '@/features/orbit/utils/helpers';
import { notePendingSuggestion } from '@/features/orbit/utils/pendingResend';
import { findSessionPlaylistId, readOrbitState, writeOrbitHeartbeat } from '@/features/orbit/utils/remote';
export class OrbitJoinError extends Error {
constructor(
public readonly reason: 'not-found' | 'ended' | 'full' | 'kicked' | 'no-user' | 'server-error',
message: string,
) {
super(message);
this.name = 'OrbitJoinError';
}
}
/**
* Guest: join an existing session by id.
*
* Assumes the user is already authenticated against the correct Navidrome
* server — the caller's UI layer handles the magic-sharing flow when the
* encoded server in the share link doesn't match the active one.
*
* Side effects on success:
* - creates this user's outbox playlist and writes a first heartbeat
* - binds `useOrbitStore` to the session (role = guest, phase = active)
* - populates the store's `state` mirror with the last-known blob
*
* Throws `OrbitJoinError` on any gate failure; caller shows an error
* modal and does nothing else.
*/
export async function joinOrbitSession(sid: string): Promise<OrbitState> {
const server = useAuthStore.getState().getActiveServer();
const username = server?.username;
if (!username) throw new OrbitJoinError('no-user', 'No active Navidrome server / user');
const store = useOrbitStore.getState();
if (store.phase !== 'idle') {
throw new OrbitJoinError('server-error', `Cannot join while phase is ${store.phase}`);
}
store.setPhase('joining');
let outboxPlaylistId: string | null = null;
try {
// 1) Locate the session playlist and read its state blob.
const sessionPlaylistId = await findSessionPlaylistId(sid);
if (!sessionPlaylistId) throw new OrbitJoinError('not-found', `Session ${sid} not found on server`);
const state = await readOrbitState(sessionPlaylistId);
if (!state) throw new OrbitJoinError('not-found', `Session ${sid} has no valid state`);
if (state.ended) throw new OrbitJoinError('ended', `Session ${sid} has ended`);
// 2) Gate: not kicked, not full. Note: host isn't in `participants` itself,
// so `maxUsers` counts guests only.
if (state.kicked.includes(username)) {
throw new OrbitJoinError('kicked', `You were removed from session ${sid}`);
}
const alreadyInside = state.participants.some(p => p.user === username);
if (!alreadyInside && state.participants.length >= state.maxUsers) {
throw new OrbitJoinError('full', `Session ${sid} is full (${state.maxUsers}/${state.maxUsers})`);
}
// 3) Create our outbox + first heartbeat.
const outboxName = orbitOutboxPlaylistName(sid, username);
// Guard against a stale outbox from a previous abandoned join attempt —
// if one exists under the same name, reuse its id instead of creating
// a duplicate (Navidrome allows duplicate names but it'd leak).
// A *transient* getPlaylists failure must not make us create a duplicate
// outbox — distinguish "lookup failed" (undefined) from "genuinely absent"
// (null) and retry only on failure before falling back to create.
const lookupOutbox = async () => {
try {
return (await getPlaylists(true)).find(p => p.name === outboxName) ?? null;
} catch {
return undefined;
}
};
let existing = await lookupOutbox();
if (existing === undefined) existing = await lookupOutbox();
if (existing) {
outboxPlaylistId = existing.id;
} else {
const outbox = await createPlaylist(outboxName);
outboxPlaylistId = outbox.id;
}
await writeOrbitHeartbeat(outboxPlaylistId, outboxName);
// 4) Bind the local store. The host's next poll will register us in
// `participants` — we don't self-mutate the canonical state.
useOrbitStore.setState({
role: 'guest',
sessionId: sid,
sessionPlaylistId,
outboxPlaylistId,
phase: 'active',
state,
errorMessage: null,
joinedAt: Date.now(),
});
return state;
} catch (err) {
// Best-effort cleanup.
if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* ignore */ } }
useOrbitStore.getState().setPhase('idle');
throw err;
}
}
/**
* Guest: leave a session voluntarily.
*
* Deletes our outbox (so the host stops counting us after its next sweep)
* and resets the local store. Best-effort on each step. Does NOT touch the
* canonical session playlist — that's the host's property.
*/
export async function leaveOrbitSession(): Promise<void> {
const { role, outboxPlaylistId } = useOrbitStore.getState();
if (role !== 'guest') return;
if (outboxPlaylistId) {
try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ }
}
useOrbitStore.getState().reset();
}
/** Why a guest's suggestion would be blocked, in priority order. `null` means
* the suggestion can proceed. */
export type OrbitSuggestGateReason = 'not-guest' | 'muted' | null;
/**
* Evaluate whether the local guest is allowed to send a new suggestion right
* now — used by both the UI (to disable buttons / show toasts) and
* {@link suggestOrbitTrack} as a defensive check.
*/
export function evaluateOrbitSuggestGate(): { allowed: boolean; reason: OrbitSuggestGateReason } {
const { role, state } = useOrbitStore.getState();
if (role !== 'guest' || !state) return { allowed: false, reason: 'not-guest' };
const username = useAuthStore.getState().getActiveServer()?.username ?? '';
if (state.suggestionBlocked?.includes(username)) {
return { allowed: false, reason: 'muted' };
}
return { allowed: true, reason: null };
}
export class OrbitSuggestBlockedError extends Error {
constructor(public readonly reason: Exclude<OrbitSuggestGateReason, null>) {
super(`Suggestion blocked: ${reason}`);
this.name = 'OrbitSuggestBlockedError';
}
}
/**
* Guest: suggest a track to the session.
*
* Appends the track to our own outbox playlist. The host's next sweep will
* consume it and publish the authoritative queue update in the state blob.
* No state mutation here — the guest never touches canonical state.
*/
export async function suggestOrbitTrack(trackId: string): Promise<void> {
const gate = evaluateOrbitSuggestGate();
if (!gate.allowed && gate.reason && gate.reason !== 'not-guest') {
throw new OrbitSuggestBlockedError(gate.reason);
}
const { role, outboxPlaylistId, sessionId } = useOrbitStore.getState();
if (role !== 'guest') throw new Error('Not joined to a session as a guest');
if (!outboxPlaylistId || !sessionId) throw new Error('No outbox bound');
await ensureTrackInOutbox(outboxPlaylistId, trackId);
// Record the suggestion locally so the UI can surface it as "waiting on
// host" until the host's next sweep merges it into the shared queue.
// Drained by the guest tick's reconcilePendingSuggestions call; tracked for
// lost-update re-send by notePendingSuggestion.
useOrbitStore.getState().addPendingSuggestion(trackId);
notePendingSuggestion(trackId);
}
/**
* Append a track to an outbox playlist unless it's already there. Subsonic's
* playlist update replaces the song list wholesale, so we carry the existing
* ids along. Shared by the initial suggest and the guest tick's lost-update
* re-send (where a no-op append means the host already cleared + recorded it).
*/
export async function ensureTrackInOutbox(outboxPlaylistId: string, trackId: string): Promise<void> {
const { songs } = await getPlaylist(outboxPlaylistId);
const ids = songs.map(s => s.id);
if (ids.includes(trackId)) return;
await updatePlaylist(outboxPlaylistId, [...ids, trackId], ids.length);
}
/**
* Host: accept a guest suggestion and route it into the live play queue.
* No-op outside host role. Uses the shared `mergedSuggestionKeys` store
* slot so the tick doesn't re-process the same item.
*/
export async function approveOrbitSuggestion(q: OrbitQueueItem): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state) return;
try {
const song = await getSong(q.trackId);
if (!song) return;
const track = songToTrack(song);
usePlayerStore.getState().enqueue([track]);
store.addMergedSuggestion(suggestionKey(q));
} catch { /* silent */ }
}
/**
* Host: reject a guest suggestion. It stays in `OrbitState.queue` as
* history but is filtered out of the approval UI and the merge tick.
*/
export function declineOrbitSuggestion(q: OrbitQueueItem): void {
const store = useOrbitStore.getState();
if (store.role !== 'host') return;
store.addDeclinedSuggestion(suggestionKey(q));
}
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import {
ORBIT_STATE_MAX_BYTES,
makeInitialOrbitState,
parseOrbitState,
type OrbitQueueItem,
type OrbitState,
} from '@/features/orbit/api/orbit';
import {
makeCoalescedRunner,
OrbitStateTooLarge,
serialiseOrbitState,
serialiseOrbitStateForWire,
} from '@/features/orbit/utils/helpers';
function baseState(): OrbitState {
return makeInitialOrbitState({ sid: 'aaaa1111', host: 'host', name: 'sesh' });
}
function makeQueue(n: number): OrbitQueueItem[] {
// addedAt == index, so "oldest" is the lowest addedAt.
return Array.from({ length: n }, (_, i) => ({
trackId: `track-${i}-${'x'.repeat(40)}`,
addedBy: `user${i % 10}`,
addedAt: i,
}));
}
function byteLen(s: string): number {
return new TextEncoder().encode(s).length;
}
describe('serialiseOrbitStateForWire', () => {
it('passes a within-budget state through untouched', () => {
const state = { ...baseState(), queue: makeQueue(5) };
expect(serialiseOrbitStateForWire(state)).toBe(serialiseOrbitState(state));
});
it('trims oldest suggestions until the blob fits the byte budget', () => {
const state = { ...baseState(), queue: makeQueue(300) };
// Sanity: the untrimmed state really is over budget.
expect(() => serialiseOrbitState(state)).toThrow(OrbitStateTooLarge);
const wire = serialiseOrbitStateForWire(state);
expect(byteLen(wire)).toBeLessThanOrEqual(ORBIT_STATE_MAX_BYTES);
const parsed = parseOrbitState(JSON.parse(wire));
expect(parsed).not.toBeNull();
const retained = parsed!.queue;
expect(retained.length).toBeGreaterThan(0);
expect(retained.length).toBeLessThan(300);
// The dropped entries are the oldest — every retained addedAt is a
// contiguous suffix ending at the newest (299).
const addedAts = retained.map(q => q.addedAt);
expect(Math.max(...addedAts)).toBe(299);
expect(Math.min(...addedAts)).toBe(300 - retained.length);
});
it('falls back to trimming the play queue once history is exhausted', () => {
const playQueue = Array.from({ length: 400 }, (_, i) => ({
trackId: `pq-${i}-${'y'.repeat(40)}`,
addedBy: `user${i % 10}`,
}));
const state: OrbitState = { ...baseState(), queue: [], playQueue, playQueueTotal: playQueue.length };
expect(() => serialiseOrbitState(state)).toThrow(OrbitStateTooLarge);
const wire = serialiseOrbitStateForWire(state);
expect(byteLen(wire)).toBeLessThanOrEqual(ORBIT_STATE_MAX_BYTES);
const parsed = parseOrbitState(JSON.parse(wire));
expect(parsed!.queue).toEqual([]);
expect((parsed!.playQueue ?? []).length).toBeLessThan(400);
});
});
describe('makeCoalescedRunner', () => {
// Let the microtask queue drain so an awaiting do-while loop can advance.
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
it('never runs two task bodies concurrently and coalesces mid-flight calls into one rerun', async () => {
let starts = 0;
const releases: Array<() => void> = [];
const task = () => {
starts++;
return new Promise<void>(res => { releases.push(res); });
};
const run = makeCoalescedRunner(task);
void run(); // run #1 starts and blocks
void run(); // in-flight → flags a rerun, no second body
void run(); // in-flight → still just one pending rerun
expect(starts).toBe(1);
releases[0](); // finish #1 → the coalesced rerun fires exactly one more body
await flush();
expect(starts).toBe(2);
releases[1](); // finish #2 → no rerun pending, runner goes idle
await flush();
expect(starts).toBe(2);
void run(); // lock released → a fresh call starts a new body
expect(starts).toBe(3);
});
});
+133
View File
@@ -0,0 +1,133 @@
import {
ORBIT_PLAYLIST_PREFIX,
ORBIT_STATE_MAX_BYTES,
type OrbitOutboxMeta,
type OrbitQueueItem,
type OrbitState,
} from '@/features/orbit/api/orbit';
/** 8 lowercase hex chars — unique enough for concurrent-session collision-free naming. */
export function generateSessionId(): string {
const bytes = new Uint8Array(4);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Serialise the state blob for writing into a playlist comment. Emits a
* plain JSON string. Throws when the output exceeds `ORBIT_STATE_MAX_BYTES`
* — callers should trim optional fields (oldest queue entries / kicked
* usernames) and retry, rather than write something truncated.
*/
export function serialiseOrbitState(state: OrbitState): string {
const json = JSON.stringify(state);
// Encode-length check — emoji-heavy session names could inflate UTF-8 bytes
// beyond the string's .length count.
const byteLen = new TextEncoder().encode(json).length;
if (byteLen > ORBIT_STATE_MAX_BYTES) {
throw new OrbitStateTooLarge(byteLen);
}
return json;
}
export class OrbitStateTooLarge extends Error {
constructor(public readonly bytes: number) {
super(`Orbit state blob (${bytes} bytes) exceeds ${ORBIT_STATE_MAX_BYTES} byte budget`);
this.name = 'OrbitStateTooLarge';
}
}
function trySerialise(state: OrbitState): string | null {
try {
return serialiseOrbitState(state);
} catch (e) {
if (e instanceof OrbitStateTooLarge) return null;
throw e;
}
}
/**
* Serialise the state blob for the wire, shedding the least-important data
* instead of throwing when it would exceed the byte budget. Without this a
* single over-budget tick makes `writeOrbitState` throw, the host swallows it
* and retries the same too-large state forever — guests freeze and time out.
*
* Sheds, in order: oldest attribution history (`queue`), then the tail of the
* published `playQueue`. Operates on copies — the caller's local store keeps
* full state; only the published blob shrinks, which is all guests consume.
* If even a minimal blob overflows (pathological session name / participant
* list) it throws `OrbitStateTooLarge` as before, so the caller still logs.
*/
export function serialiseOrbitStateForWire(state: OrbitState): string {
const direct = trySerialise(state);
if (direct !== null) return direct;
// Drop oldest suggestions first — they're the least useful for attribution.
const queue = [...state.queue].sort((a, b) => a.addedAt - b.addedAt);
while (queue.length > 0) {
queue.shift();
const out = trySerialise({ ...state, queue });
if (out !== null) return out;
}
// Queue exhausted and still too large — shorten the published play queue.
const playQueue = [...(state.playQueue ?? [])];
while (playQueue.length > 0) {
playQueue.pop();
const out = trySerialise({ ...state, queue: [], playQueue });
if (out !== null) return out;
}
// Even the minimal blob overflows — surface it so the host tick logs it.
return serialiseOrbitState({ ...state, queue: [], playQueue: [] });
}
export function serialiseOutboxMeta(meta: OrbitOutboxMeta): string {
return JSON.stringify(meta);
}
/**
* Stable per-suggestion key across reshuffles — `addedBy`, `addedAt` and
* `trackId` are all immutable once the host sweep has written them.
* Shared between the host tick and the manual-approval UI.
*/
export const suggestionKey = (q: OrbitQueueItem): string =>
`${q.addedBy}:${q.addedAt}:${q.trackId}`;
/**
* Wrap an async task so it never runs concurrently with itself. While a run is
* in flight, further calls do NOT start a second run — they flag a rerun, and
* exactly one more run fires after the current finishes (any number of
* mid-flight requests coalesce into a single catch-up). The host tick uses this
* because `pushState` does several awaited round-trips and is fired from a 2.5 s
* timer, the mount, and a play/pause subscription that can overlap; without
* serialisation a slow run that already swept+cleared an outbox can lose its
* write to a faster run (last-writer-wins), dropping those suggestions.
*/
export function makeCoalescedRunner(task: () => Promise<void>): () => Promise<void> {
let running = false;
let rerun = false;
return async () => {
if (running) {
rerun = true;
return;
}
running = true;
try {
do {
rerun = false;
await task();
} while (rerun);
} finally {
running = false;
}
};
}
/** Extract `<username>` from a filename matching `__psyorbit_<sid>_from_<username>__`. */
export function parseOutboxPlaylistName(name: string, sid: string): string | null {
const prefix = `${ORBIT_PLAYLIST_PREFIX}${sid}_from_`;
if (!name.startsWith(prefix) || !name.endsWith('__')) return null;
const user = name.slice(prefix.length, name.length - 2);
return user.length > 0 ? user : null;
}
+82
View File
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
makeInitialOrbitState,
ORBIT_DEFAULT_SETTINGS,
type OrbitSettings,
type OrbitState,
} from '@/features/orbit/api/orbit';
const { writeOrbitState } = vi.hoisted(() => ({ writeOrbitState: vi.fn(() => Promise.resolve()) }));
const { orbitStore } = vi.hoisted(() => ({
orbitStore: {
role: 'host' as 'host' | 'guest' | null,
state: null as OrbitState | null,
sessionPlaylistId: 'session-pl' as string | null,
setState: vi.fn(),
},
}));
vi.mock('@/features/orbit/utils/remote', () => ({
writeOrbitState,
writeOrbitHeartbeat: vi.fn(() => Promise.resolve()),
}));
vi.mock('@/features/orbit/store/orbitStore', () => ({ useOrbitStore: { getState: () => orbitStore } }));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => ({}) } }));
vi.mock('@/store/playerStore', () => ({ usePlayerStore: { getState: () => ({ enqueue: vi.fn() }) } }));
vi.mock('@/api/subsonicPlaylists', () => ({ createPlaylist: vi.fn(), deletePlaylist: vi.fn() }));
vi.mock('@/api/subsonicLibrary', () => ({ getSong: vi.fn() }));
vi.mock('@/utils/playback/songToTrack', () => ({ songToTrack: vi.fn() }));
import { updateOrbitSettings } from '@/features/orbit/utils/host';
function hostStateWith(settings: OrbitSettings | undefined): OrbitState {
const base = makeInitialOrbitState({ sid: 'aaaa1111', host: 'host', name: 'sesh' });
return { ...base, settings: settings as OrbitSettings };
}
/** The settings object that updateOrbitSettings persisted on its last call. */
function writtenSettings(): OrbitSettings {
const calls = writeOrbitState.mock.calls as unknown as Array<[string, OrbitState]>;
const lastCall = calls[calls.length - 1];
return lastCall[1].settings as OrbitSettings;
}
beforeEach(() => {
writeOrbitState.mockClear();
orbitStore.setState.mockClear();
orbitStore.role = 'host';
orbitStore.sessionPlaylistId = 'session-pl';
});
describe('updateOrbitSettings', () => {
it('does not silently flip autoApprove on a legacy settings-less session', async () => {
orbitStore.state = hostStateWith(undefined);
await updateOrbitSettings({ autoShuffle: false });
const settings = writtenSettings();
// The patch only touched autoShuffle…
expect(settings.autoShuffle).toBe(false);
// …autoApprove must come from the canonical default (false), not flip true.
expect(settings.autoApprove).toBe(ORBIT_DEFAULT_SETTINGS.autoApprove);
expect(settings.autoApprove).toBe(false);
expect(settings.shuffleIntervalMin).toBe(ORBIT_DEFAULT_SETTINGS.shuffleIntervalMin);
});
it('preserves existing settings when patching one field', async () => {
orbitStore.state = hostStateWith({ autoApprove: true, autoShuffle: true, shuffleIntervalMin: 30 });
await updateOrbitSettings({ autoShuffle: false });
const settings = writtenSettings();
expect(settings.autoApprove).toBe(true);
expect(settings.autoShuffle).toBe(false);
expect(settings.shuffleIntervalMin).toBe(30);
});
it('is a no-op when not hosting', async () => {
orbitStore.role = 'guest';
orbitStore.state = hostStateWith(undefined);
await updateOrbitSettings({ autoShuffle: false });
expect(writeOrbitState).not.toHaveBeenCalled();
});
});
+212
View File
@@ -0,0 +1,212 @@
import { createPlaylist, deletePlaylist } from '@/api/subsonicPlaylists';
import { getSong } from '@/api/subsonicLibrary';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useAuthStore } from '@/store/authStore';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { usePlayerStore } from '@/store/playerStore';
import {
makeInitialOrbitState,
orbitOutboxPlaylistName,
orbitSessionPlaylistName,
ORBIT_DEFAULT_MAX_USERS,
ORBIT_DEFAULT_SETTINGS,
type OrbitQueueItem,
type OrbitSettings,
type OrbitState,
} from '@/features/orbit/api/orbit';
import { generateSessionId } from '@/features/orbit/utils/helpers';
import { readOrbitTransitionSettings } from '@/features/orbit/utils/transitions';
import { writeOrbitHeartbeat, writeOrbitState } from '@/features/orbit/utils/remote';
export interface StartOrbitArgs {
/** Human-readable name the host chose. */
name: string;
/** Max participants (defaults to `ORBIT_DEFAULT_MAX_USERS`). */
maxUsers?: number;
/**
* Pre-generated session id. Lets the caller (e.g. the start modal) show a
* stable share-link *before* the session is actually created. Falls back
* to a fresh id when omitted.
*/
sid?: string;
}
/**
* Host: create a new session.
*
* Creates both the canonical session playlist and the host's own outbox,
* seeds the state blob + heartbeat, binds the store, sets phase to `active`.
*
* Throws if the Navidrome server isn't available or lacks a logged-in user.
* On throw the store is left in the pre-call state — nothing partially bound.
*/
export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitState> {
const server = useAuthStore.getState().getActiveServer();
const username = server?.username;
if (!username) throw new Error('No active Navidrome server / user');
const store = useOrbitStore.getState();
if (store.phase !== 'idle') {
throw new Error(`Cannot start while phase is ${store.phase}`);
}
store.setPhase('starting');
let sessionPlaylistId: string | null = null;
let outboxPlaylistId: string | null = null;
try {
const sid = args.sid ?? generateSessionId();
const sessionName = orbitSessionPlaylistName(sid);
const outboxName = orbitOutboxPlaylistName(sid, username);
// Create both playlists. Navidrome's createPlaylist returns the created
// object with its new id.
const sessionPlaylist = await createPlaylist(sessionName);
sessionPlaylistId = sessionPlaylist.id;
const outboxPlaylist = await createPlaylist(outboxName);
outboxPlaylistId = outboxPlaylist.id;
// Seed state blob + heartbeat. We use updatePlaylistMeta instead of
// separate create-with-comment because Subsonic's createPlaylist doesn't
// take a comment argument.
const state = makeInitialOrbitState({
sid,
host: username,
name: args.name,
maxUsers: args.maxUsers ?? ORBIT_DEFAULT_MAX_USERS,
});
// Seed the host's current track-transition prefs so a guest joining
// immediately adopts them from the very first blob; the host tick keeps
// them fresh thereafter.
state.settings = { ...(state.settings ?? ORBIT_DEFAULT_SETTINGS), transitions: readOrbitTransitionSettings() };
await writeOrbitState(sessionPlaylistId, state);
await writeOrbitHeartbeat(outboxPlaylistId, outboxName);
// Bind local store — session is now live.
useOrbitStore.setState({
role: 'host',
sessionId: sid,
sessionPlaylistId,
outboxPlaylistId,
phase: 'active',
state,
errorMessage: null,
joinedAt: Date.now(),
});
return state;
} catch (err) {
// Best-effort cleanup of anything we managed to create before the failure.
if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* ignore */ } }
if (sessionPlaylistId) { try { await deletePlaylist(sessionPlaylistId); } catch { /* ignore */ } }
useOrbitStore.getState().setPhase('idle');
throw err;
}
}
/**
* Host: end the session cleanly.
*
* Writes `ended: true` first so any poll-in-progress from a guest sees the
* signal, then deletes both playlists and resets the local store. Each step
* is best-effort; if something's already gone server-side we still zero out
* local state so the UI returns to idle.
*/
export async function endOrbitSession(): Promise<void> {
const { role, state, sessionPlaylistId, outboxPlaylistId } = useOrbitStore.getState();
if (role !== 'host') return;
// 1) Flip `ended` so guests notice on their next poll even if deletion fails.
if (sessionPlaylistId && state) {
try {
await writeOrbitState(sessionPlaylistId, { ...state, ended: true });
} catch { /* best-effort */ }
}
// 2) Delete both playlists. Order: outbox first — if session delete fails,
// a stale session playlist with ended=true is fine; a stale outbox without
// a session is noise.
if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ } }
if (sessionPlaylistId) { try { await deletePlaylist(sessionPlaylistId); } catch { /* best-effort */ } }
// 3) Local teardown.
useOrbitStore.getState().reset();
}
/**
* Host-only: force an immediate shuffle of the upcoming play queue, bump
* `lastShuffle` so the automatic 15-min timer resets, and push the new
* state to Navidrome. Ignores the `autoShuffle` setting — this is an
* explicit user action.
*/
export async function triggerOrbitShuffleNow(): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) return;
// 1) Shuffle the host's real play queue (upcoming only).
usePlayerStore.getState().shuffleUpcomingQueue();
// 2) Shuffle the OrbitState.queue (guest-facing suggestion history) +
// bump lastShuffle so the auto-shuffle timer restarts.
const now = Date.now();
const shuffled = store.state.queue.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const next: OrbitState = { ...store.state, queue: shuffled, lastShuffle: now };
store.setState(next);
try { await writeOrbitState(store.sessionPlaylistId, next); }
catch { /* best-effort; next host-tick will push */ }
}
/**
* Host-only: update the session settings and immediately push to Navidrome
* so guests see the change on their next poll. No-op unless the caller is
* the current host with an active session.
*/
export async function updateOrbitSettings(patch: Partial<OrbitSettings>): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) return;
// Fall back to the canonical defaults (not a hand-rolled literal) for
// legacy sessions whose blob predates the settings field — otherwise
// patching one field on a settings-less session would silently flip
// autoApprove on, since the old literal had it true while
// ORBIT_DEFAULT_SETTINGS (the popover's source of truth) has it false.
const mergedSettings: OrbitSettings = {
...(store.state.settings ?? ORBIT_DEFAULT_SETTINGS),
...patch,
};
const next: OrbitState = { ...store.state, settings: mergedSettings };
store.setState(next);
try { await writeOrbitState(store.sessionPlaylistId, next); }
catch { /* best-effort; next host-tick will push the current state anyway */ }
}
/**
* Host: add a track to the active Orbit session directly, skipping the
* outbox/approval loop guests go through. The track lands in the host's
* own play queue immediately and is attributed to the host in the
* session's suggestion history. Host-authored queue items are filtered
* out of the tick-merge pipeline so the host-tick doesn't re-insert the
* same track once it notices the new entry in `OrbitState.queue`.
*/
export async function hostEnqueueToOrbit(trackId: string): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) {
throw new Error('Not hosting an active Orbit session');
}
const song = await getSong(trackId);
if (!song) throw new Error('Track not found');
const track = songToTrack(song);
usePlayerStore.getState().enqueue([track]);
const item: OrbitQueueItem = { trackId, addedBy: store.state.host, addedAt: Date.now() };
const next: OrbitState = { ...store.state, queue: [...store.state.queue, item] };
store.setState(next);
try { await writeOrbitState(store.sessionPlaylistId, next); }
catch { /* best-effort; next host-tick will push the merged state anyway */ }
}
+132
View File
@@ -0,0 +1,132 @@
import { deletePlaylist, getPlaylists } from '@/api/subsonicPlaylists';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { orbitOutboxPlaylistName, type OrbitState } from '@/features/orbit/api/orbit';
import { writeOrbitState } from '@/features/orbit/utils/remote';
/**
* Host: kick a participant by username.
*
* Appends the user to `kicked`, removes them from `participants`, deletes
* their outbox playlist (so a fresh re-create is recognised as a fresh
* attempt the gate blocks), and writes the new state immediately so the
* kicked guest notices on their very next poll rather than waiting for
* the regular sweep tick.
*
* Ignored if not the host, or if the session isn't active.
*/
export async function kickOrbitParticipant(username: string): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host') return;
const state = store.state;
const sessionPlaylistId = store.sessionPlaylistId;
const sid = store.sessionId;
if (!state || !sessionPlaylistId || !sid) return;
if (username === state.host) return; // host can't self-kick
if (state.kicked.includes(username)) return; // already kicked
// 1) Delete the victim's outbox, best-effort. Finding it by name avoids
// carrying outbox ids in the state blob just for this operation.
const outboxName = orbitOutboxPlaylistName(sid, username);
try {
const all = await getPlaylists(true);
const hit = all.find(p => p.name === outboxName);
if (hit) await deletePlaylist(hit.id);
} catch { /* best-effort */ }
// 2) Update state: append kick, drop from participants. Also strip any
// pending soft-`removed` marker for the same user — the permanent ban
// supersedes it.
const nextState: OrbitState = {
...state,
kicked: [...state.kicked, username],
participants: state.participants.filter(p => p.user !== username),
removed: (state.removed ?? []).filter(r => r.user !== username),
};
useOrbitStore.getState().setState(nextState);
try {
await writeOrbitState(sessionPlaylistId, nextState);
} catch { /* best-effort; next host tick will retry via its normal push */ }
}
/**
* Host: soft-remove a participant by username.
*
* Like `kickOrbitParticipant`, but does NOT add the user to `kicked` —
* instead writes a short-lived entry to `removed`. The affected guest sees
* it on their next state-read tick and is shown a "you were removed" exit
* modal, but they are free to re-join immediately via the invite link.
*
* The marker ages out after `ORBIT_REMOVED_TTL_MS` in `applyOutboxSnapshotsToState`.
*
* Ignored if not the host, target is the host, target is permanently
* kicked, or the session isn't active.
*/
export async function removeOrbitParticipant(username: string): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host') return;
const state = store.state;
const sessionPlaylistId = store.sessionPlaylistId;
const sid = store.sessionId;
if (!state || !sessionPlaylistId || !sid) return;
if (username === state.host) return;
if (state.kicked.includes(username)) return;
// 1) Delete outbox so the guest's next heartbeat-write hits a missing
// playlist (they'll create a new one on rejoin via joinOrbitSession).
const outboxName = orbitOutboxPlaylistName(sid, username);
try {
const all = await getPlaylists(true);
const hit = all.find(p => p.name === outboxName);
if (hit) await deletePlaylist(hit.id);
} catch { /* best-effort */ }
// 2) Update state: drop from participants, append fresh `removed` marker.
// Filter any prior marker for the same user so we always carry the latest ts.
const now = Date.now();
const nextState: OrbitState = {
...state,
participants: state.participants.filter(p => p.user !== username),
removed: [
...(state.removed ?? []).filter(r => r.user !== username),
{ user: username, at: now },
],
};
useOrbitStore.getState().setState(nextState);
try {
await writeOrbitState(sessionPlaylistId, nextState);
} catch { /* best-effort */ }
}
/**
* Host: mute/unmute a participant's track suggestions.
*
* Symmetric — pass `blocked: true` to add the username to
* `state.suggestionBlocked`, `false` to remove it. The participant remains
* in the session and continues to appear in the participants list; only new
* outbox entries are silently dropped during the host's sweep. The guest UI
* reads the same flag and disables its own Suggest controls so the user
* sees a clear "muted" state instead of silent failures.
*
* No-op outside host role, when the session isn't active, when the target
* is the host themselves, or when the toggle wouldn't change anything.
*/
export async function setOrbitSuggestionBlocked(username: string, blocked: boolean): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host') return;
const state = store.state;
const sessionPlaylistId = store.sessionPlaylistId;
if (!state || !sessionPlaylistId) return;
if (username === state.host) return;
const current = state.suggestionBlocked ?? [];
const isBlocked = current.includes(username);
if (blocked === isBlocked) return;
const nextList = blocked
? [...current, username]
: current.filter(u => u !== username);
const nextState: OrbitState = { ...state, suggestionBlocked: nextList };
useOrbitStore.getState().setState(nextState);
try { await writeOrbitState(sessionPlaylistId, nextState); }
catch { /* best-effort; next host tick will re-push state */ }
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Orbit utils aggregator — re-exports every public symbol from the sibling
* `utils/*` modules under one specifier. Orbit's own components/hooks import
* from here; the rest of the app goes through the feature barrel (`index.ts`).
*
* - `constants.ts` — cadence + TTL ms values.
* - `helpers.ts` — pure utilities (id gen, serialisation, key fns).
* - `stateMath.ts` — pure state transforms (shuffle, drift, sweep merge).
* - `remote.ts` — Navidrome playlist comment I/O.
* - `shareLink.ts` — invite magic-string encode/decode.
* - `host.ts` — host session lifecycle (start, end, settings, enqueue).
* - `moderation.ts` — host kicks / soft-removes / mutes.
* - `guest.ts` — guest join/leave + suggestion pipeline + host
* approve/decline reactions.
* - `sweep.ts` — host-side outbox sweep loop.
* - `cleanup.ts` — app-start orphan playlist sweep.
*/
export {
ORBIT_HEARTBEAT_ALIVE_MS,
ORBIT_ORPHAN_TTL_MS,
ORBIT_REMOVED_TTL_MS,
ORBIT_SHUFFLE_INTERVAL_MS,
} from './constants';
export {
generateSessionId,
makeCoalescedRunner,
OrbitStateTooLarge,
serialiseOrbitState,
suggestionKey,
} from './helpers';
export { isOrbitPlaybackSyncActive } from './sessionActive';
export {
applyOutboxSnapshotsToState,
computeOrbitDriftMs,
effectiveShuffleIntervalMs,
maybeShuffleQueue,
patchOrbitState,
} from './stateMath';
export {
findSessionPlaylistId,
readOrbitState,
writeOrbitHeartbeat,
writeOrbitState,
} from './remote';
export {
readOrbitTransitionSettings,
applyOrbitTransitionSettings,
saveGuestTransitionsOnce,
restoreGuestTransitions,
} from './transitions';
export {
buildOrbitShareLink,
parseOrbitShareLink,
type OrbitShareLink,
} from './shareLink';
export {
endOrbitSession,
hostEnqueueToOrbit,
startOrbitSession,
triggerOrbitShuffleNow,
updateOrbitSettings,
type StartOrbitArgs,
} from './host';
export {
kickOrbitParticipant,
removeOrbitParticipant,
setOrbitSuggestionBlocked,
} from './moderation';
export {
approveOrbitSuggestion,
declineOrbitSuggestion,
ensureTrackInOutbox,
evaluateOrbitSuggestGate,
joinOrbitSession,
leaveOrbitSession,
OrbitJoinError,
OrbitSuggestBlockedError,
suggestOrbitTrack,
type OrbitSuggestGateReason,
} from './guest';
export {
forgetPendingSuggestion,
planPendingResends,
resetPendingResendState,
} from './pendingResend';
export { sweepGuestOutboxes } from './sweep';
export { cleanupOrphanedOrbitPlaylists } from './cleanup';
@@ -0,0 +1,26 @@
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { useConfirmModalStore } from '@/store/confirmModalStore';
import i18n from '@/i18n';
/**
* Ask the user before dropping many tracks into the shared Orbit queue.
*
* Returns `true` when there's no active Orbit session, when `count <= 1`, or
* when the user accepted the confirm dialog. Returns `false` only when an
* active-Orbit user explicitly cancelled.
*
* Lives in its own module so `playerStore` can use it without pulling the
* full `utils/orbit.ts` (which itself imports `playerStore` — circular).
*/
export async function orbitBulkGuard(count: number): Promise<boolean> {
const role = useOrbitStore.getState().role;
if (role !== 'host' && role !== 'guest') return true;
if (count <= 1) return true;
return useConfirmModalStore.getState().request({
title: i18n.t('orbit.bulkConfirmTitle'),
message: i18n.t('orbit.bulkConfirmBody', { count }),
confirmLabel: i18n.t('orbit.bulkConfirmYes'),
cancelLabel: i18n.t('orbit.bulkConfirmNo'),
});
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Orbit — diagnostics ring buffer.
*
* In-memory log of recent Orbit events for users who can't reach DevTools or
* Settings → Debug → Export. The Diagnostics popover in the Orbit bar reads
* this buffer live and offers a one-click clipboard copy so a Discord user
* can paste the buffer straight into a bug-report channel.
*
* Events are also bridged to `frontend_debug_log` when the user has Debug
* logging on in Settings, so the same data lands in `psysonic-logs-*.log`
* for power users who want a persistent file.
*/
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '@/store/authStore';
/** Hard cap so a long session doesn't spend memory unbounded. ~200 entries. */
const MAX_EVENTS = 200;
export interface OrbitDiagEvent {
/** Wall-clock ms when the event was pushed. */
ts: number;
/** Short tag, e.g. `pull`, `divergence`, `audio:ended`, `host:state`. */
scope: string;
/** Already-stringified message body. Whitespace + line breaks preserved. */
message: string;
}
const buffer: OrbitDiagEvent[] = [];
const subscribers = new Set<() => void>();
/** Frozen snapshot for `useSyncExternalStore`. Replaced on every mutation;
* identical between mutations so React doesn't keep re-rendering. */
let snapshot: readonly OrbitDiagEvent[] = [];
function notify() {
snapshot = buffer.slice();
for (const sub of subscribers) {
try { sub(); } catch { /* never let one bad listener kill the rest */ }
}
}
/**
* Append an event to the ring. Also fires `frontend_debug_log` so the same
* line shows up in the runtime log file when Debug mode is enabled.
*
* Safe to call from anywhere — never throws, never blocks.
*/
export function pushOrbitEvent(scope: string, message: string | Record<string, unknown>): void {
const text = typeof message === 'string' ? message : safeStringify(message);
const evt: OrbitDiagEvent = { ts: Date.now(), scope, message: text };
buffer.push(evt);
if (buffer.length > MAX_EVENTS) buffer.splice(0, buffer.length - MAX_EVENTS);
notify();
// Bridge to the existing Rust log buffer when Debug mode is on, so
// Settings → Debug → Export still works for the same data.
if (useAuthStore.getState().loggingMode === 'debug') {
void invoke('frontend_debug_log', {
scope: `orbit:${scope}`,
message: text,
}).catch(() => { /* best-effort */ });
}
}
function safeStringify(obj: Record<string, unknown>): string {
try { return JSON.stringify(obj); }
catch { return '[unserialisable]'; }
}
/** Snapshot of all currently-buffered events, oldest first.
* Returns the SAME reference between mutations so `useSyncExternalStore`
* can detect "nothing changed" and skip the render. */
export function getOrbitEvents(): readonly OrbitDiagEvent[] {
return snapshot;
}
/** Wipe the buffer. Used by the Clear button in the Diagnostics popover. */
export function clearOrbitEvents(): void {
buffer.length = 0;
notify();
}
/** Subscribe to buffer mutations. Returns the unsubscribe function. */
export function subscribeOrbitEvents(listener: () => void): () => void {
subscribers.add(listener);
return () => { subscribers.delete(listener); };
}
/** Format the buffer as a copy-pasteable plain-text block. */
export function formatOrbitEvents(events: readonly OrbitDiagEvent[]): string {
if (events.length === 0) return '';
const lines = events.map(e => {
const stamp = new Date(e.ts).toISOString();
return `[${stamp}] [${e.scope}] ${e.message}`;
});
return lines.join('\n');
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Orbit — random session-name suggester.
*
* Combinatorial generator over three patterns:
* 1. `${adj} ${noun}` (e.g. "Velvet Orbit")
* 2. `${phrase} ${noun}` (e.g. "Late Night Rooftop")
* 3. `${noun} ${suffix}` (e.g. "Rooftop Sessions")
*
* Pools are hand-curated so that random combinations almost always still
* read as a coherent, music-themed session name. Total addressable name
* space is in the tens of thousands — you'd have to reshuffle very hard
* to see the same one twice.
*/
const ADJECTIVES: readonly string[] = [
'Velvet', 'Neon', 'Midnight', 'Golden', 'Static', 'Cosmic',
'Late', 'Lost', 'Deep', 'Slow', 'Quiet', 'Electric',
'Analog', 'Distant', 'Hidden', 'Frozen', 'Warm', 'Wild',
'Silver', 'Amber', 'Crystal', 'Endless', 'Hazy', 'Drifting',
'Restless', 'Secret', 'Weightless', 'Echoing', 'Low', 'Bright',
'Soft', 'Dusty', 'Foggy', 'Smoky', 'Twilight', 'Dawning',
'Infinite', 'Eternal', 'Vintage', 'Smooth', 'Silent', 'Faint',
'Bold', 'Sharp', 'Tender', 'Savage', 'Gentle', 'Reckless',
'Chill', 'Steaming', 'Burning', 'Icy', 'Muted', 'Vivid',
'Prismatic', 'Shadowy', 'Liminal', 'Spectral', 'Faded', 'Sleepy',
'Wandering', 'Roaming', 'Dreaming', 'Floating', 'Buzzing', 'Rolling',
'Hushed', 'Broken', 'Wired', 'Outer', 'Moonlit', 'Sunlit',
'Firelit', 'Candlelit', 'Quiet', 'Howling', 'Whispered', 'Shimmering',
'Dusky', 'Drowsy', 'Plush', 'Opalescent', 'Silken',
];
const NOUNS: readonly string[] = [
// places
'Rooftop', 'Kitchen', 'Basement', 'Garage', 'Lounge', 'Diner',
'Cinema', 'Harbor', 'Highway', 'Hotel', 'Parlor', 'Attic',
'Balcony', 'Terrace', 'Patio', 'Studio', 'Warehouse', 'Pier',
'Terminal', 'Platform', 'Corner', 'Boulevard', 'Tower', 'Lighthouse',
'Chapel', 'Bunker', 'Courtyard', 'Observatory', 'Arcade', 'Alleyway',
// media / musical
'Tape', 'Radio', 'Session', 'Rotation', 'Mixtape', 'Transmission',
'Frequency', 'Broadcast', 'Channel', 'Cassette', 'Reel', 'Loop',
'Vinyl', 'Sleeve', 'Waveform', 'Echo', 'Reverb', 'Bassline',
'Bridge', 'Interlude', 'Mix', 'Playlist', 'Chord', 'Groove',
'Encore', 'Setlist', 'Tracklist', 'Dub', 'Bootleg',
// celestial / orbit-y
'Orbit', 'Galaxy', 'Nebula', 'Comet', 'Horizon', 'Signal',
'Drift', 'Satellite', 'Atmosphere', 'Starfield', 'Eclipse', 'Nova',
'Moon', 'Void', 'Prism', 'Meteor', 'Solstice', 'Equinox',
'Zenith', 'Apogee', 'Pulsar', 'Quasar', 'Aurora', 'Supernova',
];
const PHRASES: readonly string[] = [
'Late Night', 'Deep Space', 'Low-Fi', 'Low-Key', 'Slow Burn',
'Afterhour', 'Golden Hour', 'Blue Hour', 'Off-Grid', 'Outer Space',
'Northern Light', 'Velvet Night', 'Neon Drive', 'Midnight Drive',
'Quiet Storm', 'Slow Motion', 'Electric Dream', 'Analog Dream',
'Hidden Track', 'Side-B', 'Dark-Side', 'Back-Room', 'Morning-After',
'Last-Call', 'First-Light', 'Long-Play', 'Cold-Start', 'Warm-Up',
'Sunset', 'Afterparty',
];
const SUFFIXES: readonly string[] = [
'Sessions', 'Radio', 'Tapes', 'Transmissions', 'Rotations',
'Mixes', 'Broadcasts', 'Frequencies', 'Interludes', 'Playback',
'Nights', 'Hours', 'Signals', 'Takes', 'Bootlegs',
];
function pickRandom<T>(list: readonly T[]): T {
return list[Math.floor(Math.random() * list.length)];
}
/** Returns a fresh combinatorial suggestion. */
export function randomOrbitSessionName(): string {
const r = Math.random();
if (r < 0.20) {
// "Rooftop Sessions"
return `${pickRandom(NOUNS)} ${pickRandom(SUFFIXES)}`;
}
if (r < 0.40) {
// "Late Night Rooftop"
return `${pickRandom(PHRASES)} ${pickRandom(NOUNS)}`;
}
// Default: "Velvet Orbit"
return `${pickRandom(ADJECTIVES)} ${pickRandom(NOUNS)}`;
}
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
forgetPendingSuggestion,
notePendingSuggestion,
ORBIT_SUGGESTION_GIVE_UP_MS,
ORBIT_SUGGESTION_RESEND_GRACE_MS,
pendingResendTrackedCount,
planPendingResends,
resetPendingResendState,
} from '@/features/orbit/utils/pendingResend';
const T0 = 1_000_000;
const GRACE = ORBIT_SUGGESTION_RESEND_GRACE_MS;
beforeEach(() => {
resetPendingResendState();
});
describe('planPendingResends', () => {
it('does nothing within the grace window', () => {
notePendingSuggestion('t1', T0);
const plan = planPendingResends(['t1'], new Set(), T0 + GRACE - 1);
expect(plan).toEqual({ resend: [], giveUp: [] });
});
it('re-sends once per grace window when the host has not recorded it', () => {
notePendingSuggestion('t1', T0);
// First window elapsed → one re-send.
expect(planPendingResends(['t1'], new Set(), T0 + GRACE + 1).resend).toEqual(['t1']);
// Still inside the second window → no further re-send.
expect(planPendingResends(['t1'], new Set(), T0 + GRACE + 100).resend).toEqual([]);
// Second window elapsed → re-send again.
expect(planPendingResends(['t1'], new Set(), T0 + GRACE * 2 + 1).resend).toEqual(['t1']);
});
it('leaves a host-recorded suggestion alone even past the grace window', () => {
notePendingSuggestion('t1', T0);
const plan = planPendingResends(['t1'], new Set(['t1']), T0 + GRACE * 3);
expect(plan).toEqual({ resend: [], giveUp: [] });
});
it('gives up once past the give-up window', () => {
notePendingSuggestion('t1', T0);
const plan = planPendingResends(['t1'], new Set(), T0 + ORBIT_SUGGESTION_GIVE_UP_MS + 1);
expect(plan.giveUp).toEqual(['t1']);
expect(plan.resend).toEqual([]);
});
it('seeds tracking for a suggestion it sees for the first time, acting next tick', () => {
// No prior note — first plan call just starts the clock.
const first = planPendingResends(['t1'], new Set(), T0);
expect(first).toEqual({ resend: [], giveUp: [] });
expect(pendingResendTrackedCount()).toBe(1);
// A grace window after that seed → re-send.
expect(planPendingResends(['t1'], new Set(), T0 + GRACE + 1).resend).toEqual(['t1']);
});
it('forget + reset clear tracking', () => {
notePendingSuggestion('t1', T0);
notePendingSuggestion('t2', T0);
expect(pendingResendTrackedCount()).toBe(2);
forgetPendingSuggestion('t1');
expect(pendingResendTrackedCount()).toBe(1);
resetPendingResendState();
expect(pendingResendTrackedCount()).toBe(0);
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* Guest-side mitigation for the outbox lost-update race.
*
* A track a guest appends to its outbox can be wiped by a concurrent host
* sweep-clear (`updatePlaylist(outbox, [], n)`) before the host actually
* recorded it — the suggestion is lost AND stays stuck on "waiting on host"
* forever, because the guest only clears it once it reaches the host's play
* queue. The read-modify-write on a Subsonic playlist can't be made atomic, so
* instead we recover:
*
* - A pending suggestion the host has NOT recorded (absent from
* `state.queue`, the suggestion history every received submission lands in)
* past a grace window is re-sent. The host dedupes by (user, trackId), so
* re-sending is idempotent — a slow-but-not-lost suggestion just no-ops.
* - Past the give-up window it is dropped so the UI stops hanging (host gone,
* guest muted, or over the cap).
*
* Recorded-but-not-yet-merged suggestions (manual-approval mode) are left
* alone — the host has them, they're legitimately waiting.
*/
/** ~3 host ticks: long enough that a normal-latency suggestion isn't re-sent. */
export const ORBIT_SUGGESTION_RESEND_GRACE_MS = 7_500;
/** Stop re-sending / waiting once a suggestion is this old and still unrecorded. */
export const ORBIT_SUGGESTION_GIVE_UP_MS = 45_000;
interface PendingMeta {
addedAt: number;
resends: number;
}
// Module-level so it survives guest-hook remounts within a single session.
const meta = new Map<string, PendingMeta>();
/** Start tracking a suggestion the moment it's submitted. Idempotent. */
export function notePendingSuggestion(trackId: string, now: number = Date.now()): void {
if (!meta.has(trackId)) meta.set(trackId, { addedAt: now, resends: 0 });
}
/** Stop tracking a suggestion (it landed, was given up, or the user left). */
export function forgetPendingSuggestion(trackId: string): void {
meta.delete(trackId);
}
/** Clear all tracking — call on leaving / ending a session. */
export function resetPendingResendState(): void {
meta.clear();
}
/** Test-only: number of suggestions currently tracked. */
export function pendingResendTrackedCount(): number {
return meta.size;
}
export interface PendingResendPlan {
/** Re-append these to the outbox — the host hasn't recorded them yet. */
resend: string[];
/** Drop these from the pending UI — they never reached the host. */
giveUp: string[];
}
/**
* Decide which still-pending suggestions to re-send and which to give up on.
* `recordedByHost` = trackIds in the host's `state.queue` (received, so not
* lost — leave them alone even if not yet merged into the play queue).
*
* Mutates the internal retry bookkeeping; returns the actions for this tick.
*/
export function planPendingResends(
pending: readonly string[],
recordedByHost: ReadonlySet<string>,
now: number = Date.now(),
): PendingResendPlan {
const resend: string[] = [];
const giveUp: string[] = [];
for (const trackId of pending) {
let m = meta.get(trackId);
if (!m) {
// First time we see it here (e.g. submitted before notePending ran) —
// start the clock, act next tick.
m = { addedAt: now, resends: 0 };
meta.set(trackId, m);
continue;
}
if (recordedByHost.has(trackId)) continue; // host has it — not lost
const age = now - m.addedAt;
if (age > ORBIT_SUGGESTION_GIVE_UP_MS) {
giveUp.push(trackId);
continue;
}
// One re-send per grace window, paced by how many we've already done.
if (age > ORBIT_SUGGESTION_RESEND_GRACE_MS * (m.resends + 1)) {
m.resends += 1;
resend.push(trackId);
}
}
return { resend, giveUp };
}
+64
View File
@@ -0,0 +1,64 @@
import { getPlaylist, getPlaylists, updatePlaylistMeta } from '@/api/subsonicPlaylists';
import {
orbitSessionPlaylistName,
parseOrbitState,
type OrbitOutboxMeta,
type OrbitState,
} from '@/features/orbit/api/orbit';
import { serialiseOrbitStateForWire, serialiseOutboxMeta } from '@/features/orbit/utils/helpers';
/** Pull + parse the canonical state from the session playlist. Null on miss or parse error. */
export async function readOrbitState(sessionPlaylistId: string): Promise<OrbitState | null> {
try {
const { playlist } = await getPlaylist(sessionPlaylistId);
if (!playlist.comment) return null;
let raw: unknown;
try { raw = JSON.parse(playlist.comment); } catch { return null; }
return parseOrbitState(raw);
} catch { return null; }
}
/**
* Write the state blob into the session playlist's comment.
*
* NOTE (design doc "known rough edges"): `updatePlaylist.view` with name +
* comment MUST preserve the track list. Confirmed to work on Navidrome via
* observation in PR #256 (playlist-editor); if a future Navidrome release
* ever changes that, we need to switch to `updatePlaylist` with the full
* track list echoed back.
*/
export async function writeOrbitState(
sessionPlaylistId: string,
state: OrbitState,
): Promise<void> {
const comment = serialiseOrbitStateForWire(state);
const name = orbitSessionPlaylistName(state.sid);
await updatePlaylistMeta(sessionPlaylistId, name, comment, /* public */ true);
}
/**
* Write a heartbeat into the given outbox playlist's comment. Host keeps one
* for symmetry + to feed its own presence into the participants pipeline
* (used from Phase 4 onwards when guests look for host liveness).
*/
export async function writeOrbitHeartbeat(
outboxPlaylistId: string,
outboxName: string,
): Promise<void> {
const meta: OrbitOutboxMeta = { ts: Date.now() };
await updatePlaylistMeta(outboxPlaylistId, outboxName, serialiseOutboxMeta(meta), /* public */ true);
}
/**
* Find the Navidrome playlist id of a session given its session id.
* Scans the user's visible playlist list — Navidrome exposes public
* playlists from other users, so a guest can find the host's session.
*/
export async function findSessionPlaylistId(sid: string): Promise<string | null> {
const target = orbitSessionPlaylistName(sid);
try {
const all = await getPlaylists(true);
const hit = all.find(p => p.name === target);
return hit?.id ?? null;
} catch { return null; }
}
+14
View File
@@ -0,0 +1,14 @@
import type { OrbitPhase, OrbitRole } from '@/features/orbit/store/orbitStore';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
/**
* True while this client is in an Orbit session on the shared playback path
* (host/guest during start, join, or active play). Matches previewStore guard.
*/
export function isOrbitPlaybackSyncActive(
role: OrbitRole | null = useOrbitStore.getState().role,
phase: OrbitPhase = useOrbitStore.getState().phase,
): boolean {
if (role !== 'host' && role !== 'guest') return false;
return phase === 'active' || phase === 'joining' || phase === 'starting';
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { buildOrbitShareLink, parseOrbitShareLink } from '@/features/orbit/utils/shareLink';
describe('parseOrbitShareLink', () => {
it('round-trips an https invite', () => {
const link = buildOrbitShareLink('https://music.example.com', 'aaaa1111');
expect(parseOrbitShareLink(link)).toEqual({
serverBase: 'https://music.example.com',
sid: 'aaaa1111',
});
});
it('accepts a plain http server', () => {
const link = buildOrbitShareLink('http://192.168.1.10:4533', 'bbbb2222');
expect(parseOrbitShareLink(link)?.serverBase).toBe('http://192.168.1.10:4533');
});
it('rejects http-prefixed but non-http(s) schemes that slip past url normalization', () => {
// normalizeShareServerUrl only prepends http:// when the string doesn't
// already start with "http", so these reach the parser unchanged — the
// protocol allow-list is what rejects them.
for (const bad of ['httpx://evil.example', 'https-phish://evil.example']) {
const link = buildOrbitShareLink(bad, 'cccc3333');
expect(parseOrbitShareLink(link)).toBeNull();
}
});
it('rejects a non-URL server and empty input', () => {
expect(parseOrbitShareLink(buildOrbitShareLink('not a url', 'dddd4444'))).toBeNull();
expect(parseOrbitShareLink('')).toBeNull();
expect(parseOrbitShareLink('garbage-not-a-share-string')).toBeNull();
});
});
+30
View File
@@ -0,0 +1,30 @@
import { decodeOrbitSharePayloadFromText, encodeSharePayload } from '@/utils/share/shareLink';
export interface OrbitShareLink {
/** Base URL of the Navidrome server (decoded). */
serverBase: string;
/** Session id (8 hex chars). */
sid: string;
}
/**
* Parse an orbit invite from pasted text. Accepts the magic-string format
* `psysonic2-<base64url-json>` (same prefix family as library shares and
* server invites). The caller decides what to do on null (show toast, etc.).
*/
export function parseOrbitShareLink(text: string): OrbitShareLink | null {
if (!text) return null;
const payload = decodeOrbitSharePayloadFromText(text);
if (!payload) return null;
let url: URL;
try { url = new URL(payload.srv); } catch { return null; }
// Only http(s) — a pasted invite must not smuggle file:/javascript:/etc.
// Neutralized downstream by the known-server match too, but reject early.
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return { serverBase: payload.srv, sid: payload.sid };
}
/** Build an orbit invite magic string for a live session. */
export function buildOrbitShareLink(serverBase: string, sid: string): string {
return encodeSharePayload({ srv: serverBase, k: 'orbit', sid });
}
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('@/features/orbit/store/orbitStore', () => ({
useOrbitStore: { getState: () => ({ state: null, setState: vi.fn() }) },
}));
import { makeInitialOrbitState, type OrbitQueueItem, type OrbitState } from '@/features/orbit/api/orbit';
import { ORBIT_QUEUE_HISTORY_LIMIT } from '@/features/orbit/utils/constants';
import { applyOutboxSnapshotsToState, type OutboxSnapshot } from '@/features/orbit/utils/stateMath';
function stateWithQueue(queue: OrbitQueueItem[]): OrbitState {
return { ...makeInitialOrbitState({ sid: 'aaaa1111', host: 'host', name: 'sesh' }), queue };
}
describe('applyOutboxSnapshotsToState — queue history cap', () => {
it('drops the oldest entries when new suggestions push past the limit', () => {
// A full history (addedAt 0…limit-1) plus 10 brand-new suggestions.
const existing: OrbitQueueItem[] = Array.from({ length: ORBIT_QUEUE_HISTORY_LIMIT }, (_, i) => ({
trackId: `old-${i}`,
addedBy: 'old',
addedAt: i,
}));
const state = stateWithQueue(existing);
const now = 1_000_000;
const snapshots: OutboxSnapshot[] = [
{
user: 'bob',
outboxPlaylistId: 'ob',
trackIds: Array.from({ length: 10 }, (_, i) => `new-${i}`),
lastHeartbeat: now,
},
];
const next = applyOutboxSnapshotsToState(state, snapshots, now);
expect(next.queue.length).toBe(ORBIT_QUEUE_HISTORY_LIMIT);
// All 10 new suggestions survive…
for (let i = 0; i < 10; i++) {
expect(next.queue.some(q => q.trackId === `new-${i}`)).toBe(true);
}
// …and the 10 oldest were evicted.
for (let i = 0; i < 10; i++) {
expect(next.queue.some(q => q.trackId === `old-${i}`)).toBe(false);
}
// The youngest retained "old" entries are still present.
expect(next.queue.some(q => q.trackId === `old-${ORBIT_QUEUE_HISTORY_LIMIT - 1}`)).toBe(true);
});
it('leaves a sub-limit queue untouched', () => {
const state = stateWithQueue([{ trackId: 't0', addedBy: 'old', addedAt: 0 }]);
const now = 1_000_000;
const next = applyOutboxSnapshotsToState(
state,
[{ user: 'bob', outboxPlaylistId: 'ob', trackIds: ['t1'], lastHeartbeat: now }],
now,
);
expect(next.queue.map(q => q.trackId)).toEqual(['t0', 't1']);
});
});
describe('applyOutboxSnapshotsToState — maxUsers enforcement', () => {
const NOW = 5_000;
const fresh = (user: string, trackIds: string[] = []): OutboxSnapshot => ({
user,
outboxPlaylistId: `ob-${user}`,
trackIds,
lastHeartbeat: NOW,
});
function hostState(maxUsers: number, participants: OrbitState['participants']): OrbitState {
return {
...makeInitialOrbitState({ sid: 'aaaa1111', host: 'host', name: 'sesh', maxUsers }),
participants,
};
}
it('admits at most maxUsers guests, dropping the surplus', () => {
const state = hostState(2, []);
const next = applyOutboxSnapshotsToState(
state,
[fresh('aaa'), fresh('bbb'), fresh('ccc')],
NOW,
);
// Three guests joined on the same tick; the deterministic username
// tie-break keeps the first two alphabetically.
expect(next.participants.map(p => p.user)).toEqual(['aaa', 'bbb']);
});
it('never displaces an established participant for a newcomer', () => {
// `zoe` joined earlier (smaller joinedAt) — she keeps her slot even though
// her name sorts last.
const state = hostState(2, [{ user: 'zoe', joinedAt: 1_000, lastHeartbeat: 1_000 }]);
const next = applyOutboxSnapshotsToState(
state,
[fresh('zoe'), fresh('aaa'), fresh('bbb')],
NOW,
);
expect(next.participants.map(p => p.user)).toEqual(['zoe', 'aaa']);
});
it('ignores suggestions from an over-cap guest', () => {
const state = hostState(2, []);
const next = applyOutboxSnapshotsToState(
state,
[fresh('aaa', ['t-a']), fresh('bbb', ['t-b']), fresh('ccc', ['t-c'])],
NOW,
);
const addedTracks = next.queue.map(q => q.trackId);
expect(addedTracks).toContain('t-a');
expect(addedTracks).toContain('t-b');
// ccc is over the cap → not a participant, suggestion ignored.
expect(addedTracks).not.toContain('t-c');
});
});
+171
View File
@@ -0,0 +1,171 @@
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import type {
OrbitParticipant,
OrbitQueueItem,
OrbitState,
} from '@/features/orbit/api/orbit';
import {
ORBIT_HEARTBEAT_ALIVE_MS,
ORBIT_QUEUE_HISTORY_LIMIT,
ORBIT_REMOVED_TTL_MS,
ORBIT_SHUFFLE_INTERVAL_MS,
} from '@/features/orbit/utils/constants';
/**
* Keep `OrbitState.queue` bounded. Drops the oldest suggestions (by `addedAt`)
* once the history exceeds the limit — `queue` is periodically shuffled, so a
* positional trim could discard recent entries; ordering by `addedAt` keeps
* the trim honest. The dropped tracks have long since played, so losing their
* attribution / dedupe entry is harmless.
*/
function capQueueHistory(queue: OrbitQueueItem[]): OrbitQueueItem[] {
if (queue.length <= ORBIT_QUEUE_HISTORY_LIMIT) return queue;
return [...queue]
.sort((a, b) => a.addedAt - b.addedAt)
.slice(queue.length - ORBIT_QUEUE_HISTORY_LIMIT);
}
/** Merge a patch into the store's state blob, keeping nullability. */
export function patchOrbitState(patch: Partial<OrbitState>): OrbitState | null {
const current = useOrbitStore.getState().state;
if (!current) return null;
const next: OrbitState = { ...current, ...patch };
useOrbitStore.getState().setState(next);
return next;
}
/**
* Resolve the active auto-shuffle cadence in ms. Reads the host's configured
* preset from `state.settings.shuffleIntervalMin`; older sessions that lack
* the field fall back to 15 min so their tick cadence is unchanged.
*/
export function effectiveShuffleIntervalMs(state: Pick<OrbitState, 'settings'>): number {
const min = state.settings?.shuffleIntervalMin;
return typeof min === 'number' ? min * 60_000 : ORBIT_SHUFFLE_INTERVAL_MS;
}
/**
* Host helper — applies a Fisher-Yates shuffle to `state.queue` iff enough
* time has passed since the last shuffle. Pure, returns a new state object.
* `currentTrack` is never touched.
*/
export function maybeShuffleQueue(state: OrbitState, nowMs: number = Date.now()): OrbitState {
if (state.settings?.autoShuffle === false) return state;
if (nowMs - state.lastShuffle < effectiveShuffleIntervalMs(state)) return state;
if (state.queue.length < 2) {
// Still bump `lastShuffle` so the next eligible shuffle is one full
// interval away, preventing a tight retry loop right after a guest
// drops a single item in.
return { ...state, lastShuffle: nowMs };
}
const shuffled = state.queue.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return { ...state, queue: shuffled, lastShuffle: nowMs };
}
/** Drift between a guest's local playback and the host's estimated live position. */
export function computeOrbitDriftMs(state: OrbitState, guestPositionMs: number, nowMs: number = Date.now()): number {
const hostEstimated = state.positionMs + (state.isPlaying ? (nowMs - state.positionAt) : 0);
return guestPositionMs - hostEstimated;
}
export interface OutboxSnapshot {
user: string;
outboxPlaylistId: string;
/** Track IDs currently sitting in the outbox — these are the new suggestions. */
trackIds: string[];
/** Last heartbeat timestamp parsed from the outbox comment, or 0 if missing/broken. */
lastHeartbeat: number;
}
/**
* Fold sweep results into an updated `OrbitState`.
*
* - New queue items are appended to `state.queue`, with `addedBy` = user
* and `addedAt` = now. Host-authored tracks (host's own currentTrack
* progression) are handled elsewhere and don't flow through this path.
* - `participants` is rebuilt from scratch from the sweep heartbeats —
* anyone with a fresh heartbeat (< `ORBIT_HEARTBEAT_ALIVE_MS` old) and
* not in `kicked` counts as alive. Users that disappear from the sweep
* age out naturally.
*/
export function applyOutboxSnapshotsToState(
state: OrbitState,
snapshots: OutboxSnapshot[],
nowMs: number = Date.now(),
): OrbitState {
// ── Soft-removed list aging ──
// Drop entries older than the TTL so the list stays bounded and a long-
// expired marker doesn't kick a freshly-rejoined user back out.
const removed = (state.removed ?? []).filter(r => nowMs - r.at < ORBIT_REMOVED_TTL_MS);
const removedUsers = new Set(removed.map(r => r.user));
// ── Participants rebuild (with maxUsers enforcement) ──
// Anyone with a fresh heartbeat who isn't kicked or soft-removed is
// *eligible*. Soft-removed users stay out even if their heartbeat is still
// fresh — gives them up to one read tick (~2.5s) to notice the `removed`
// marker and tear down their guest hooks before it ages out.
//
// The join gate (`joinOrbitSession`) only sees a one-tick-old blob, so two
// guests can both pass the `participants.length < maxUsers` check and slip
// in past the cap at once. The host is the single writer of the canonical
// state, so it's the only place the cap can truly hold — enforce it here by
// admitting at most `maxUsers` guests. Earliest joiners win (an established
// participant is never displaced by a newcomer; their `joinedAt` is older),
// with a deterministic username tie-break for guests that joined on the
// same tick. The host runs its own outbox heartbeat but is filtered out by
// the sweep, so `participants` is guests only — the cap matches `maxUsers`.
const prev = new Map(state.participants.map(p => [p.user, p]));
const eligible: OrbitParticipant[] = [];
for (const snap of snapshots) {
if (state.kicked.includes(snap.user)) continue;
if (removedUsers.has(snap.user)) continue;
const fresh = snap.lastHeartbeat > 0 && (nowMs - snap.lastHeartbeat) < ORBIT_HEARTBEAT_ALIVE_MS;
if (!fresh) continue;
const existing = prev.get(snap.user);
eligible.push({
user: snap.user,
joinedAt: existing?.joinedAt ?? nowMs,
lastHeartbeat: snap.lastHeartbeat,
});
}
eligible.sort((a, b) => a.joinedAt - b.joinedAt || a.user.localeCompare(b.user));
const participants = eligible.slice(0, Math.max(0, state.maxUsers));
const admitted = new Set(participants.map(p => p.user));
// ── Queue additions ──
// Guest outboxes are append-only from the host's POV — the host reads the
// same playlist every sweep, so we dedupe against anything already in
// `state.queue` (or currently playing) by (user, trackId). Without this,
// every host tick re-adds every outbox entry and the pending-approval list
// balloons indefinitely. A suggestion only counts from an *admitted* guest
// who isn't muted — an over-cap guest's outbox is ignored, consistent with
// their absence from `participants`.
const existingKeys = new Set<string>(
state.queue.map(q => `${q.addedBy} ${q.trackId}`),
);
if (state.currentTrack) {
existingKeys.add(`${state.currentTrack.addedBy} ${state.currentTrack.trackId}`);
}
const blocked = new Set(state.suggestionBlocked ?? []);
const newItems: OrbitQueueItem[] = [];
for (const snap of snapshots) {
if (blocked.has(snap.user) || !admitted.has(snap.user)) continue;
for (const trackId of snap.trackIds) {
const key = `${snap.user} ${trackId}`;
if (existingKeys.has(key)) continue;
existingKeys.add(key);
newItems.push({ trackId, addedBy: snap.user, addedAt: nowMs });
}
}
return {
...state,
queue: newItems.length > 0 ? capQueueHistory([...state.queue, ...newItems]) : state.queue,
participants,
removed,
};
}
+66
View File
@@ -0,0 +1,66 @@
import { getPlaylist, getPlaylists, updatePlaylist } from '@/api/subsonicPlaylists';
import { type OrbitOutboxMeta } from '@/features/orbit/api/orbit';
import { parseOutboxPlaylistName } from '@/features/orbit/utils/helpers';
import { type OutboxSnapshot } from '@/features/orbit/utils/stateMath';
/**
* Host: list all guest outbox playlists for the current session.
* Skips the host's own outbox — that's heartbeat-only, not a suggestion channel.
*/
async function listGuestOutboxes(sid: string, hostUsername: string): Promise<Array<{ id: string; name: string; user: string }>> {
const all = await getPlaylists(true).catch(() => []);
const result: Array<{ id: string; name: string; user: string }> = [];
for (const p of all) {
const user = parseOutboxPlaylistName(p.name, sid);
if (!user || user === hostUsername) continue;
result.push({ id: p.id, name: p.name, user });
}
return result;
}
/**
* Host: read one outbox's contents (suggested tracks + heartbeat ts).
*/
async function readOutbox(playlistId: string): Promise<{ trackIds: string[]; lastHeartbeat: number }> {
try {
const { playlist, songs } = await getPlaylist(playlistId);
let ts = 0;
if (playlist.comment) {
try {
const meta = JSON.parse(playlist.comment) as Partial<OrbitOutboxMeta>;
if (typeof meta.ts === 'number') ts = meta.ts;
} catch { /* malformed — treat as no heartbeat */ }
}
return { trackIds: songs.map(s => s.id), lastHeartbeat: ts };
} catch {
return { trackIds: [], lastHeartbeat: 0 };
}
}
/**
* Host: sweep every guest outbox once.
*
* - Collects suggested track IDs from each outbox (returns them so the
* caller can wire them into the state queue with `addedBy` = user).
* - Captures the latest heartbeat ts per user for the participants list.
* - Clears the outbox track list after reading — a single-pass consume
* semantic: once the host has seen a track, the guest doesn't need to
* show it as "pending" any longer. The outbox's heartbeat comment is
* left untouched because the guest's own heartbeat hook keeps refreshing it.
*
* Returns a list of snapshots, one per live guest outbox. Errors on
* individual outboxes are swallowed — best-effort.
*/
export async function sweepGuestOutboxes(sid: string, hostUsername: string): Promise<OutboxSnapshot[]> {
const outboxes = await listGuestOutboxes(sid, hostUsername);
const snaps: OutboxSnapshot[] = [];
for (const ob of outboxes) {
const { trackIds, lastHeartbeat } = await readOutbox(ob.id);
snaps.push({ user: ob.user, outboxPlaylistId: ob.id, trackIds, lastHeartbeat });
if (trackIds.length > 0) {
// Clear the outbox tracks. Leaves the heartbeat comment untouched.
try { await updatePlaylist(ob.id, [], trackIds.length); } catch { /* best-effort */ }
}
}
return snaps;
}
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { OrbitTransitionSettings } from '@/features/orbit/api/orbit';
const { store, setState } = vi.hoisted(() => {
const store = {
state: {
crossfadeEnabled: false,
crossfadeSecs: 3,
crossfadeTrimSilence: false,
autodjSmoothSkip: true,
gaplessEnabled: false,
autodjOverlapCapMode: 'auto',
autodjOverlapCapSec: 15,
} as Record<string, unknown>,
};
const setState = vi.fn((patch: Record<string, unknown>) => {
store.state = { ...store.state, ...patch };
});
return { store, setState };
});
vi.mock('@/store/authStore', () => ({
useAuthStore: { getState: () => store.state, setState },
}));
import {
applyOrbitTransitionSettings,
hasGuestTransitionsSnapshot,
readOrbitTransitionSettings,
restoreGuestTransitions,
saveGuestTransitionsOnce,
} from '@/features/orbit/utils/transitions';
const GUEST_OWN: OrbitTransitionSettings = {
crossfadeEnabled: false,
crossfadeSecs: 3,
crossfadeTrimSilence: false,
autodjSmoothSkip: true,
gaplessEnabled: false,
autodjOverlapCapMode: 'auto',
autodjOverlapCapSec: 15,
};
const HOST: OrbitTransitionSettings = {
crossfadeEnabled: true,
crossfadeSecs: 8,
crossfadeTrimSilence: true,
autodjSmoothSkip: false,
gaplessEnabled: false,
autodjOverlapCapMode: 'limit',
autodjOverlapCapSec: 20,
};
beforeEach(() => {
restoreGuestTransitions(); // clear any leftover snapshot from a prior test
store.state = { ...GUEST_OWN };
setState.mockClear();
});
describe('read/apply transition settings', () => {
it('reads the transition fields from the store', () => {
expect(readOrbitTransitionSettings()).toEqual(GUEST_OWN);
});
it('applies a set and is a no-op when already in sync', () => {
applyOrbitTransitionSettings(HOST);
expect(setState).toHaveBeenCalledTimes(1);
expect(readOrbitTransitionSettings()).toEqual(HOST);
// Same values again → must not churn setState (would re-fire audio sync).
applyOrbitTransitionSettings(HOST);
expect(setState).toHaveBeenCalledTimes(1);
});
});
describe('guest snapshot save/restore', () => {
it('snapshots the user own settings, adopts the host, then restores on leave', () => {
saveGuestTransitionsOnce();
expect(hasGuestTransitionsSnapshot()).toBe(true);
applyOrbitTransitionSettings(HOST);
expect(readOrbitTransitionSettings()).toEqual(HOST);
restoreGuestTransitions();
expect(readOrbitTransitionSettings()).toEqual(GUEST_OWN);
expect(hasGuestTransitionsSnapshot()).toBe(false);
});
it('save is idempotent — a second save never captures host-applied values', () => {
saveGuestTransitionsOnce(); // snapshots the user's own
applyOrbitTransitionSettings(HOST);
saveGuestTransitionsOnce(); // no-op: snapshot already held
restoreGuestTransitions();
expect(readOrbitTransitionSettings()).toEqual(GUEST_OWN);
});
it('restore is a no-op when nothing was saved', () => {
store.state = { ...HOST };
restoreGuestTransitions();
expect(readOrbitTransitionSettings()).toEqual(HOST);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { useAuthStore } from '@/store/authStore';
import type { OrbitTransitionSettings } from '@/features/orbit/api/orbit';
import {
sanitizeAutodjOverlapCapMode,
sanitizeAutodjOverlapCapSec,
} from '@/utils/playback/autodjOverlapCap';
/**
* Bridge between the local playback-transition settings (in `authStore`) and
* the `OrbitTransitionSettings` mirrored through a session.
*
* Applying a set via `setState` is enough to reach the Rust engine: the
* `authSyncListener` subscribes to `authStore` and re-pushes
* `audio_set_crossfade` / `audio_set_gapless` on every change, and the
* JS-side readers (`crossfadePreload`, smooth-skip) read the fields live. So
* we deliberately reuse that path instead of invoking the audio commands here.
*/
const FIELDS = [
'crossfadeEnabled',
'crossfadeSecs',
'crossfadeTrimSilence',
'autodjSmoothSkip',
'gaplessEnabled',
] as const;
/** Snapshot the local transition settings into an `OrbitTransitionSettings`. */
export function readOrbitTransitionSettings(): OrbitTransitionSettings {
const s = useAuthStore.getState();
return {
crossfadeEnabled: s.crossfadeEnabled,
crossfadeSecs: s.crossfadeSecs,
crossfadeTrimSilence: s.crossfadeTrimSilence,
autodjSmoothSkip: s.autodjSmoothSkip,
gaplessEnabled: s.gaplessEnabled,
autodjOverlapCapMode: s.autodjOverlapCapMode,
autodjOverlapCapSec: s.autodjOverlapCapSec,
};
}
/** True when the local settings already equal `t` (nothing to apply). */
function alreadyInSync(t: OrbitTransitionSettings): boolean {
const s = useAuthStore.getState();
if (!FIELDS.every(f => s[f] === t[f])) return false;
if (t.autodjOverlapCapMode !== undefined
&& s.autodjOverlapCapMode !== sanitizeAutodjOverlapCapMode(t.autodjOverlapCapMode)) {
return false;
}
if (t.autodjOverlapCapSec !== undefined
&& s.autodjOverlapCapSec !== sanitizeAutodjOverlapCapSec(t.autodjOverlapCapSec)) {
return false;
}
return true;
}
/**
* Apply a transition set to the local settings. No-op when already in sync, so
* a guest can call this every read tick without churning `setState` or
* re-firing the audio-engine sync.
*/
export function applyOrbitTransitionSettings(t: OrbitTransitionSettings): void {
if (alreadyInSync(t)) return;
useAuthStore.setState({
crossfadeEnabled: t.crossfadeEnabled,
crossfadeSecs: t.crossfadeSecs,
crossfadeTrimSilence: t.crossfadeTrimSilence,
autodjSmoothSkip: t.autodjSmoothSkip,
gaplessEnabled: t.gaplessEnabled,
...(t.autodjOverlapCapMode !== undefined
? { autodjOverlapCapMode: sanitizeAutodjOverlapCapMode(t.autodjOverlapCapMode) }
: {}),
...(t.autodjOverlapCapSec !== undefined
? { autodjOverlapCapSec: sanitizeAutodjOverlapCapSec(t.autodjOverlapCapSec) }
: {}),
});
}
// Guest-side snapshot of the user's own settings, kept across hook remounts so
// leave/restore is reliable even if the session bar unmounts mid-session.
let guestSaved: OrbitTransitionSettings | null = null;
/**
* Guest: save the user's own transition settings before adopting the host's.
* Idempotent — a second call without an intervening restore is a no-op, so the
* per-tick host apply can never overwrite the real snapshot with host values.
*/
export function saveGuestTransitionsOnce(): void {
if (guestSaved) return;
guestSaved = readOrbitTransitionSettings();
}
/** Guest: restore the saved settings (if any) and clear the snapshot. */
export function restoreGuestTransitions(): void {
if (!guestSaved) return;
const saved = guestSaved;
guestSaved = null;
applyOrbitTransitionSettings(saved);
}
/** Test-only: whether a guest snapshot is currently held. */
export function hasGuestTransitionsSnapshot(): boolean {
return guestSaved !== null;
}