mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(orbit): co-locate orbit feature into features/orbit
This commit is contained in:
@@ -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 effect’s 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 1–3 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 5–8 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 effect’s 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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user