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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user