diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts index f111cdcf..82a69d15 100644 --- a/src/hooks/useOrbitHost.ts +++ b/src/hooks/useOrbitHost.ts @@ -6,6 +6,7 @@ import { writeOrbitHeartbeat, sweepGuestOutboxes, applyOutboxSnapshotsToState, + maybeShuffleQueue, } from '../utils/orbit'; import { orbitOutboxPlaylistName, type OrbitState } from '../api/orbit'; @@ -79,10 +80,13 @@ export function useOrbitHost(): void { afterSweep = applyOutboxSnapshotsToState(base, snaps); } catch { /* best-effort; keep old participants and queue */ } - // 2) Overlay the host's live playback snapshot. - const next: OrbitState = { ...afterSweep, ...snapshotPlayerPatch(base.host) }; + // 2) Shuffle check — no-op unless >= 15 min since last shuffle. + const afterShuffle = maybeShuffleQueue(afterSweep); - // 3) Commit locally + push remote. + // 3) Overlay the host's live playback snapshot. + const next: OrbitState = { ...afterShuffle, ...snapshotPlayerPatch(base.host) }; + + // 4) Commit locally + push remote. useOrbitStore.getState().setState(next); try { await writeOrbitState(sessionPlaylistId, next); diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index 60ed22c6..aedae1a6 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -513,6 +513,35 @@ export async function sweepGuestOutboxes(sid: string, hostUsername: string): Pro /** 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; +/** Shuffle cadence — queue is reshuffled once every interval. */ +export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000; + +/** + * 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 (nowMs - state.lastShuffle < ORBIT_SHUFFLE_INTERVAL_MS) return state; + if (state.queue.length < 2) { + // Still bump `lastShuffle` so the next eligible shuffle is 15 min 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; +} + /** * Fold sweep results into an updated `OrbitState`. *