From 2d32ef7b2d93ec6ee212a753ab6d4ac29b6d9b95 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:48:48 +0200 Subject: [PATCH] chore(orbit): retry initial guest sync until it actually lands Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/useOrbitGuest.ts | 74 +++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts index 5258ca22..751fec7d 100644 --- a/src/hooks/useOrbitGuest.ts +++ b/src/hooks/useOrbitGuest.ts @@ -66,10 +66,10 @@ export function useOrbitGuest(): void { * past the deadline so a loading error doesn't leave the guest stuck * on a silent pause. */ - const syncToHost = async (trackId: string, hostState: OrbitState) => { + const syncToHost = async (trackId: string, hostState: OrbitState): Promise => { try { const song = await getSong(trackId); - if (!song || cancelled) return; + 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 @@ -79,37 +79,37 @@ export function useOrbitGuest(): void { const targetSec = Math.max(0, targetMs / 1000); return Math.max(0, Math.min(0.99, targetSec / Math.max(1, track.duration))); }; - const applyMirror = () => { + const applyMirror = (): boolean => { const p = usePlayerStore.getState(); - if (cancelled || p.currentTrack?.id !== trackId) return; + if (cancelled || p.currentTrack?.id !== trackId) return false; p.seek(calcFraction()); if (hostState.isPlaying && !p.isPlaying) p.resume(); else if (!hostState.isPlaying && p.isPlaying) p.pause(); + return true; }; const player = usePlayerStore.getState(); if (player.currentTrack?.id === trackId) { - applyMirror(); - return; + return applyMirror(); } player.playTrack(track, [track]); - // Poll until the engine has the track loaded AND matches host play - // state; fall back to a blind apply after 2 s so a stuck load (audio - // error reverts isPlaying → false) doesn't leave the guest silent. - const deadline = Date.now() + 2000; - const poll = () => { - if (cancelled) return; - const p = usePlayerStore.getState(); - const trackReady = p.currentTrack?.id === trackId; - const stateMatches = p.isPlaying === hostState.isPlaying; - if (trackReady && (stateMatches || p.isPlaying)) { applyMirror(); return; } - if (Date.now() >= deadline) { applyMirror(); return; } + // Poll until the engine has the track loaded; fall back to a blind + // apply after 2 s so a stuck load doesn't leave us spinning forever. + return await new Promise(resolve => { + const deadline = Date.now() + 2000; + const poll = () => { + if (cancelled) { resolve(false); return; } + const p = usePlayerStore.getState(); + const trackReady = p.currentTrack?.id === trackId; + if (trackReady && p.isPlaying) { resolve(applyMirror()); return; } + if (Date.now() >= deadline) { resolve(applyMirror()); return; } + window.setTimeout(poll, 100); + }; window.setTimeout(poll, 100); - }; - window.setTimeout(poll, 100); - } catch { /* silent */ } + }); + } catch { return false; } }; const pull = async () => { @@ -171,8 +171,18 @@ export function useOrbitGuest(): void { const last = lastAppliedRef.current; if (!last) { - if (hostTrackId) void syncToHost(hostTrackId, state); - lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying }; + // 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) { + const ok = await syncToHost(hostTrackId, state); + if (ok) lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying }; + } else { + lastAppliedRef.current = { trackId: null, isPlaying: hostPlaying }; + } } else if (last.trackId !== hostTrackId) { if (hostTrackId) void syncToHost(hostTrackId, state); else if (player.isPlaying) player.pause(); @@ -191,9 +201,23 @@ export function useOrbitGuest(): void { } }; - void pull(); - const id = window.setInterval(() => { void pull(); }, STATE_READ_TICK_MS); - return () => { cancelled = true; window.clearInterval(id); }; + // 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); + }; }, [active, sessionPlaylistId]); // ── Heartbeat ────────────────────────────────────────────────────────