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

This commit is contained in:
Psychotoxical
2026-06-30 01:02:58 +02:00
parent d6dbb615fd
commit 321d5ff6ab
93 changed files with 295 additions and 248 deletions
+261
View File
@@ -0,0 +1,261 @@
import { getSong } from '@/api/subsonicLibrary';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useEffect } from 'react';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { usePlayerStore } from '@/store/playerStore';
import {
writeOrbitState,
sweepGuestOutboxes,
applyOutboxSnapshotsToState,
maybeShuffleQueue,
effectiveShuffleIntervalMs,
makeCoalescedRunner,
readOrbitTransitionSettings,
suggestionKey,
} from '@/features/orbit/utils/orbit';
import {
ORBIT_DEFAULT_SETTINGS,
ORBIT_PLAY_QUEUE_LIMIT,
type OrbitState,
type OrbitQueueItem,
} from '@/features/orbit/api/orbit';
import { showToast } from '@/utils/ui/toast';
import i18n from '@/i18n';
import { pushOrbitEvent } from '@/features/orbit/utils/orbitDiag';
import { useOrbitOutboxHeartbeat } from '@/features/orbit/hooks/useOrbitOutboxHeartbeat';
/**
* Orbit — host-side tick hook.
*
* Mounted once at the app shell level; only does work when the local store
* says we're the host of an active session. Two independent timers:
*
* - **State tick** (2.5 s): snapshot isPlaying + position + current track
* from the player store, patch the local OrbitState, push to the
* session playlist's comment.
* - **Heartbeat tick** (10 s): refresh the host's own outbox playlist's
* comment with a fresh timestamp so the later-added participant
* pipeline can treat the host symmetrically.
*
* Writes are best-effort — a transient Navidrome outage just means guests
* see stale state for a tick or two and catch up on the next write.
* Phase 2 does not yet consume anything from guests.
*/
const STATE_TICK_MS = 2_500;
export function useOrbitHost(): void {
const role = useOrbitStore(s => s.role);
const phase = useOrbitStore(s => s.phase);
const sessionPlaylistId = useOrbitStore(s => s.sessionPlaylistId);
const outboxPlaylistId = useOrbitStore(s => s.outboxPlaylistId);
const sessionId = useOrbitStore(s => s.sessionId);
const hostName = useOrbitStore(s => s.state?.host);
const active = role === 'host' && phase === 'active' && !!sessionPlaylistId;
useEffect(() => {
if (!active || !sessionPlaylistId) return;
const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => {
const p = usePlayerStore.getState();
const now = Date.now();
return {
isPlaying: p.isPlaying,
positionMs: Math.round((p.currentTime ?? 0) * 1000),
positionAt: now,
currentTrack: p.currentTrack
? {
trackId: p.currentTrack.id,
// Locally-initiated plays are marked as authored by the host.
// Guest-suggested tracks that later become `currentTrack` will
// carry their original attribution because the queue-consume
// flow keeps the `addedBy` from the guest's outbox.
addedBy: hostUsername,
addedAt: now,
}
: null,
};
};
const pushState = async () => {
const store = useOrbitStore.getState();
const base = store.state;
if (!base) return;
// 1) Sweep every guest outbox: new suggestions + fresh heartbeats.
let afterSweep = base;
try {
const snaps = await sweepGuestOutboxes(base.sid, base.host);
afterSweep = applyOutboxSnapshotsToState(base, snaps);
} catch { /* best-effort; keep old participants and queue */ }
// 2) Merge newly-suggested items into the host's local play queue so
// guest suggestions actually start playing alongside host-chosen
// tracks. Must happen BEFORE the shuffle step so the merge decision
// tracks `addedAt` (immutable) rather than list position.
await mergeNewSuggestionsIntoQueue(afterSweep.queue);
// 3) Shuffle check:
// a) `maybeShuffleQueue` handles the OrbitState.queue (guest-facing
// suggestion list). It also bumps `lastShuffle` even when the list
// is too small to reorder — that's the authoritative 15-min marker.
// b) In parallel, we shuffle the *host's* upcoming play queue so the
// mix the guests hear actually changes. `autoShuffle=false` skips
// both.
const shouldShuffleNow = afterSweep.settings?.autoShuffle !== false
&& (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep));
const afterShuffle = maybeShuffleQueue(afterSweep);
if (shouldShuffleNow) {
const before = usePlayerStore.getState().queueItems.length;
usePlayerStore.getState().shuffleUpcomingQueue();
if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info');
}
// 4) Overlay the host's live playback snapshot. Host tick reads ids only,
// so the thin `queueItems` refs are all we need (no full Track resolve).
const playerLive = usePlayerStore.getState();
const upcoming = playerLive.queueItems.slice(playerLive.queueIndex + 1);
// Map track id → original suggester (if any). State's `queue` carries
// every suggestion we've ever seen this session, so it's the right
// attribution source even after the track has been merged into the
// host's player queue.
const suggesterByTrack = new Map<string, string>();
for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy);
const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(r => ({
trackId: r.trackId,
addedBy: suggesterByTrack.get(r.trackId) ?? base.host,
}));
const next: OrbitState = {
...afterShuffle,
...snapshotPlayerPatch(base.host),
playQueue,
playQueueTotal: upcoming.length,
// Refresh the mirrored transition prefs each tick so a mid-session
// change to the host's crossfade/gapless/AutoDJ reaches guests.
settings: { ...(afterShuffle.settings ?? ORBIT_DEFAULT_SETTINGS), transitions: readOrbitTransitionSettings() },
};
// 5) Commit locally + push remote.
useOrbitStore.getState().setState(next);
try {
await writeOrbitState(sessionPlaylistId, next);
pushOrbitEvent('host:push', JSON.stringify({
track: next.currentTrack?.trackId ?? null,
playing: next.isPlaying,
posMs: next.positionMs,
queueLen: next.playQueueTotal ?? next.playQueue?.length ?? 0,
guests: next.participants.length,
}));
} catch (e) {
pushOrbitEvent('host:push', `WRITE FAILED: ${String(e)}`);
/* best-effort; next tick retries */
}
};
/**
* Resolve each not-yet-merged suggestion via `getSong` and append to the
* player queue. Records a toast per successful append so the host notices
* guest activity. Safe to call every tick — the set filter keeps it idempotent.
*/
const mergeNewSuggestionsIntoQueue = async (items: readonly OrbitQueueItem[]) => {
// Opt-out: host turned auto-approve off. Items still accumulate in
// `OrbitState.queue` and show up in the guest view / approval list —
// they just don't flow into the host's actual play queue yet.
const store = useOrbitStore.getState();
const settings = store.state?.settings;
if (settings && settings.autoApprove === false) return;
// Host-authored items are enqueued directly by `hostEnqueueToOrbit` and
// must not flow through the merge pipeline again — otherwise the tick
// would duplicate the track into the upcoming queue. Declined items
// stay out too; merged items are the existing dedup anchor.
const hostUser = store.state?.host;
const mergedKeys = new Set(store.mergedSuggestionKeys);
const declinedKeys = new Set(store.declinedSuggestionKeys);
const pending = items.filter(q =>
q.addedBy !== hostUser
&& !mergedKeys.has(suggestionKey(q))
&& !declinedKeys.has(suggestionKey(q))
);
if (pending.length === 0) return;
// Resolve in parallel — Navidrome is fine with concurrent getSong calls.
const resolved = await Promise.all(pending.map(async q => {
try {
const song = await getSong(q.trackId);
return song ? { q, track: songToTrack(song) } : null;
} catch {
return null;
}
}));
const toEnqueue = resolved.filter((r): r is { q: OrbitQueueItem; track: ReturnType<typeof songToTrack> } => r !== null);
const markAllAsMerged = () => pending.forEach(q => store.addMergedSuggestion(suggestionKey(q)));
if (toEnqueue.length === 0) {
// Mark the failed lookups as seen anyway so we don't keep retrying
// every tick for a track the server can't serve.
markAllAsMerged();
return;
}
// Sprinkle each track at a random spot inside the upcoming range so
// guest suggestions interleave with host-picked tracks rather than
// pile up at the end (where they'd never play until the 15-min shuffle).
const player = usePlayerStore.getState();
for (const { track } of toEnqueue) {
const live = usePlayerStore.getState();
const from = Math.max(0, live.queueIndex + 1);
const to = live.queueItems.length;
const span = Math.max(1, to - from + 1);
const pos = from + Math.floor(Math.random() * span);
player.enqueueAt([track], pos);
}
markAllAsMerged();
// Friendly nudge per sweep, not per track — bundled toast if >1.
if (toEnqueue.length === 1) {
const { q, track } = toEnqueue[0];
showToast(i18n.t('orbit.toastSuggested', { user: q.addedBy, title: track.title }), 3000, 'info');
} else {
showToast(i18n.t('orbit.toastSuggestedMany', { count: toEnqueue.length }), 3000, 'info');
}
};
// Serialise pushState across its three triggers (mount, timer, play/pause
// flip): two bodies must never run concurrently, or a slow run that already
// swept+cleared an outbox can lose its write to a faster one. A request
// arriving mid-run coalesces into a single rerun so no trigger is lost.
const runPush = makeCoalescedRunner(pushState);
// Immediate push on mount so guests see fresh state without waiting
// a full tick after the host comes online.
void runPush();
const id = window.setInterval(() => { void runPush(); }, STATE_TICK_MS);
// Event-driven push on play/pause flips. Without this the worst-case
// delay between "host hits pause" and "guest stops" is two full polling
// windows (host tick + guest tick = up to 5 s) — long enough for the
// guest to noticeably run ahead. Subscribing here adds at most one
// extra write per flip; non-flip state ticks still ride the 2.5 s
// timer. Listener filters on isPlaying so currentTime ticks don't
// trigger spurious pushes.
let prevIsPlaying = usePlayerStore.getState().isPlaying;
const unsubPlayPause = usePlayerStore.subscribe((state) => {
if (state.isPlaying === prevIsPlaying) return;
prevIsPlaying = state.isPlaying;
pushOrbitEvent('host:event-push', `isPlaying flip → ${state.isPlaying}`);
void runPush();
});
return () => {
window.clearInterval(id);
unsubPlayPause();
};
}, [active, sessionPlaylistId]);
// Outbox heartbeat — shared with the guest hook; the host's outbox is keyed
// by its own `OrbitState.host` name.
useOrbitOutboxHeartbeat(active, outboxPlaylistId, sessionId, hostName);
}