import { getSong } from '../api/subsonicLibrary'; import { songToTrack } from '../utils/playback/songToTrack'; import { useEffect } from 'react'; import { useOrbitStore } from '../store/orbitStore'; import { usePlayerStore } from '../store/playerStore'; import { writeOrbitState, sweepGuestOutboxes, applyOutboxSnapshotsToState, maybeShuffleQueue, effectiveShuffleIntervalMs, makeCoalescedRunner, readOrbitTransitionSettings, suggestionKey, } from '../utils/orbit'; import { ORBIT_DEFAULT_SETTINGS, ORBIT_PLAY_QUEUE_LIMIT, type OrbitState, type OrbitQueueItem, } from '../api/orbit'; import { showToast } from '../utils/ui/toast'; import i18n from '../i18n'; import { pushOrbitEvent } from '../utils/orbitDiag'; import { useOrbitOutboxHeartbeat } from './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 => { 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(); 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 } => 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); }