import { getSong } from '@/lib/api/subsonicLibrary'; import { songToTrack } from '@/lib/media/songToTrack'; import { useEffect, useRef } from 'react'; import { useOrbitStore } from '@/features/orbit/store/orbitStore'; import { useAuthStore } from '@/store/authStore'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { readOrbitState, applyOrbitTransitionSettings, saveGuestTransitionsOnce, restoreGuestTransitions, ensureTrackInOutbox, planPendingResends, forgetPendingSuggestion, resetPendingResendState, } from '@/features/orbit/utils/orbit'; import { showToast } from '@/utils/ui/toast'; import i18n from '@/lib/i18n'; import { estimateLivePosition, type OrbitState } from '@/features/orbit/api/orbit'; import { pushOrbitEvent } from '@/features/orbit/utils/orbitDiag'; import { useOrbitOutboxHeartbeat } from '@/features/orbit/hooks/useOrbitOutboxHeartbeat'; /** * Orbit — guest-side tick hook. * * Mounted at the app shell; only does work when the local store says we're * a guest in an active session. Two independent timers: * * - **State read** (2.5 s): pull the canonical state from the session * playlist's comment and mirror it into the store. Detect session end * or own kick and tear down. * - **Heartbeat** (10 s): refresh the guest's own outbox playlist * comment so the host's participant sweep sees the user as alive. * * Reads are best-effort; a transient Navidrome outage just delays state * updates by a tick or two. The session continues locally as long as the * playback engine has the current track loaded. */ const STATE_READ_TICK_MS = 2_500; /** * Host must be quiet (no state writes) for this long before we treat the * session as dead and auto-leave. Well above any normal network blip — * reconnects inside this window are silent. Tuned per user decision: * manual exits have priority, short reconnects never trigger auto-close. */ const HOST_TIMEOUT_MS = 5 * 60_000; export function useOrbitGuest(): 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 myName = useAuthStore(s => s.getActiveServer()?.username); const active = role === 'guest' && phase === 'active' && !!sessionPlaylistId; /** * Last host playback state we *applied* to the local player. Compared * against the new tick to detect host-side flips (track change / * play-pause toggle) and against the local player's current state to * detect guest-side divergence (the guest paused or skipped on their own). * * Reset to null on (re-)activation so a fresh session re-syncs from scratch. */ const lastAppliedRef = useRef<{ trackId: string | null; isPlaying: boolean } | null>(null); // ── State read + end/kick detection + auto-sync to host ────────────── useEffect(() => { if (!active || !sessionPlaylistId) return; let cancelled = false; lastAppliedRef.current = null; // Snapshot the user's own transition prefs once, before the first tick // adopts the host's — restored on leave by this effect's cleanup. saveGuestTransitionsOnce(); /** * Load `trackId` into the local player and seek to the host's live * position. Mirrors the host's `isPlaying` state — a guest joining a * paused host doesn't auto-start, a guest joining a playing host must * start. Best-effort; silent on miss. * * Seek + state-mirror is applied once the engine reports the target * track as `isPlaying` (polled up to 2 s), with a final fallback apply * past the deadline so a loading error doesn't leave the guest stuck * on a silent pause. */ const syncToHost = async (trackId: string, hostState: OrbitState): Promise => { try { const song = await getSong(trackId); 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 // seek past the end would immediately trigger audio:ended. const calcFraction = () => { const targetMs = estimateLivePosition(hostState, Date.now()); const targetSec = Math.max(0, targetMs / 1000); return Math.max(0, Math.min(0.99, targetSec / Math.max(1, track.duration))); }; const applyMirror = (): boolean => { const p = usePlayerStore.getState(); if (cancelled || p.currentTrack?.id !== trackId) return false; p.seek(calcFraction()); // Defer the play-state mirror so the seek's `audio_seek` invoke // arrives at the engine before pause/resume. `player.seek` is // debounced via setTimeout(0); `pause`/`resume` fire their // invokes synchronously — without the delay the play-state // change can race ahead of the seek and leave the engine in // the wrong position. if (hostState.isPlaying !== p.isPlaying) { window.setTimeout(() => { const fresh = usePlayerStore.getState(); if (cancelled || fresh.currentTrack?.id !== trackId) return; if (hostState.isPlaying && !fresh.isPlaying) fresh.resume(); else if (!hostState.isPlaying && fresh.isPlaying) fresh.pause(); }, 200); } return true; }; const player = usePlayerStore.getState(); const sameTrack = player.currentTrack?.id === trackId; // Take the cheap path only when the engine is actually in the // state the host expects. If the track is loaded but the engine // never reported `isPlaying === true` (slow cold-start, audio- // device warmup), this branch used to fire `seek` + `resume` // into a stuck engine — the seek silently no-oped and `resume` // can't restart a track that never started. Result: guest sees // "synced" but hears nothing until the next host-driven track // change kicks a fresh `playTrack`. Falling through to a fresh // `playTrack` here re-initialises the engine instead. if (sameTrack && player.isPlaying === hostState.isPlaying) { return applyMirror(); } if (sameTrack && player.isPlaying && !hostState.isPlaying) { // We're playing but host is paused — pause locally without // re-loading the track. return applyMirror(); } player.playTrack(track, [track]); // Poll until the engine actually reports the track playing — the // earlier "blind apply at deadline" path could fire a seek into a // not-yet-ready engine, where the seek silently no-ops and the // guest plays from 0 while believing they're synced (the visible // 50 % jump-on-Catch-Up symptom). Wait for `p.isPlaying === true` // up to 5 s, then give up and let the outer pull tick retry — // the syncToHost-failed path keeps `lastAppliedRef` null so the // 500 ms fast-poll in `tick` will try again immediately. return await new Promise(resolve => { const deadline = Date.now() + 5000; const poll = () => { if (cancelled) { resolve(false); return; } const p = usePlayerStore.getState(); const trackReady = p.currentTrack?.id === trackId; // Wait for the engine to *actually* be playing, not just for // `isPlaying = true` (which `playTrack` flips synchronously // before the audio engine has produced a single sample). // Also require `currentTime > 0.1` — once audio has flowed // past the cold-start barrier, the engine is genuinely // playing and a `seek` will commit. Without this check the // seek inside `applyMirror` lands on a not-yet-ready engine, // silently no-ops, and the engine's first progress events // overwrite the optimistic store position — the visible // symptom on join is "the waveform shows the host's live // position for a second, then snaps back to 0:00". const enginePlaying = trackReady && p.isPlaying && (p.currentTime ?? 0) > 0.1; if (enginePlaying) { resolve(applyMirror()); return; } if (Date.now() >= deadline) { resolve(false); return; } window.setTimeout(poll, 100); }; window.setTimeout(poll, 100); }); } catch { return false; } }; const pull = async () => { const state = await readOrbitState(sessionPlaylistId); if (cancelled) return; if (!state) { // Session playlist is gone — almost always means the host ended the // session and the `ended:true` write was missed because we polled // after the subsequent playlist delete. Surface the same modal the // explicit `state.ended` branch does; the store still holds the last // known state so the modal can render the host + session name copy. // Outbox cleanup runs from the modal's OK handler via leaveOrbitSession. pushOrbitEvent('pull', 'state read returned null — playlist gone, ending session'); useOrbitStore.getState().setPhase('ended'); return; } useOrbitStore.getState().setState(state); // Adopt the host's track-transition prefs for the session — idempotent, // only writes when they actually changed. Absent on pre-transition-sync // hosts, in which case the guest keeps its own. if (state.settings?.transitions) { applyOrbitTransitionSettings(state.settings.transitions); } // Auto-leave after prolonged host silence. We keep polling as long as // state reads succeed (short reconnects are silent), but if the host // hasn't written a fresh state blob for > HOST_TIMEOUT_MS we treat the // session as effectively dead and surface the exit modal. Manual exit // still works instantly — the bar's X button short-circuits this path. if (state.positionAt > 0 && (Date.now() - state.positionAt) > HOST_TIMEOUT_MS) { useOrbitStore.getState().setError('host-timeout'); return; } // Reconcile pending guest suggestions against the host's *playable* // queue — NOT `state.queue`, which is the suggestion history (every // submission lands there immediately, even under manual-approval mode // where the host hasn't actually accepted the track yet). // `state.playQueue` is the host's real upcoming queue, so a trackId // appearing there (or as `currentTrack`) means the host has merged it. if (useOrbitStore.getState().pendingSuggestions.length > 0) { const landed = new Set(); for (const q of (state.playQueue ?? [])) landed.add(q.trackId); if (state.currentTrack) landed.add(state.currentTrack.trackId); useOrbitStore.getState().reconcilePendingSuggestions(landed); landed.forEach(forgetPendingSuggestion); // Mitigate the outbox lost-update race: a suggestion the host hasn't // recorded (absent from state.queue, where every *received* submission // lands) past a grace window was likely wiped by a racing sweep-clear // — re-send it (the host dedupes, so this is idempotent). Give up + // toast on ones that never land so the row doesn't hang forever. const stillPending = useOrbitStore.getState().pendingSuggestions; if (stillPending.length > 0 && outboxPlaylistId) { const recorded = new Set(state.queue.map(q => q.trackId)); const plan = planPendingResends(stillPending, recorded); for (const trackId of plan.resend) { void ensureTrackInOutbox(outboxPlaylistId, trackId).catch(() => {}); } if (plan.giveUp.length > 0) { useOrbitStore.getState().reconcilePendingSuggestions(new Set(plan.giveUp)); plan.giveUp.forEach(forgetPendingSuggestion); showToast(i18n.t('orbit.toastSuggestLost'), 3500, 'error'); } } } // Host signalled session end: surface via `phase`, let the UI handle // the modal. Outbox cleanup still happens via leaveOrbitSession(). if (state.ended) { useOrbitStore.getState().setPhase('ended'); return; } // Kicked / soft-removed: transition into the error phase with a // matching errorMessage so the UI can pick the right copy. const me = useAuthStore.getState().getActiveServer()?.username; if (me && state.kicked.includes(me)) { useOrbitStore.getState().setError('kicked'); return; } // Soft-remove: only react to markers strictly newer than our own join // time, otherwise a stale marker from a prior session-life would // immediately bounce us out on rejoin. if (me && state.removed && state.removed.length > 0) { const joinedAt = useOrbitStore.getState().joinedAt ?? 0; const hit = state.removed.find(r => r.user === me && r.at > joinedAt); if (hit) { useOrbitStore.getState().setError('removed'); return; } } // ── Auto-sync host playback into local player ── // Rules: // 1. First tick after activation → mirror host (initial join sync, // no need for the guest to click catch-up to get started). // 2. Track changed at host → guest follows ONLY if they haven't // locally diverged. A guest who hit pause should stay paused // even when the host moves to the next song; otherwise their // pause button silently un-does itself. If diverged, we just // advance the anchor so Catch Up stays the opt-in path. // 3. Same track, host flipped play/pause → mirror only if the local // player still matches our last-applied host state. If the guest // paused/resumed locally, we leave them alone — they have to // click catch-up to opt back in. const player = usePlayerStore.getState(); const hostTrackId = state.currentTrack?.trackId ?? null; const hostPlaying = state.isPlaying; const last = lastAppliedRef.current; pushOrbitEvent('pull', JSON.stringify({ host: { track: hostTrackId, playing: hostPlaying, posMs: state.positionMs, posAt: state.positionAt }, guest: { track: player.currentTrack?.id ?? null, playing: player.isPlaying, posSec: Math.round(player.currentTime ?? 0) }, last, })); // Engine-recovery: detect a silent `audio_play` failure after our // optimistic `isPlaying: true` mark. `playTrack` flips the store // flag synchronously before the Tauri call resolves, so the // post-playTrack poll sees `isPlaying === true` even when the // engine never actually started; if `audio_play` later rejects, // the catch handler sets `isPlaying: false`. Without this check // the divergence-detection branches all pass (last/host both // think we're playing) and the guest stays stuck silent. Reset // `lastAppliedRef` so the next iteration re-runs initial-sync. if ( last && last.isPlaying && !player.isPlaying && hostPlaying && last.trackId === hostTrackId && last.trackId === player.currentTrack?.id ) { pushOrbitEvent('engine-recovery', 'engine fell back to paused while host plays — re-syncing'); lastAppliedRef.current = null; } // Re-read after the recovery check above may have reset it. const currentLast = lastAppliedRef.current; if (!currentLast) { // 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) { pushOrbitEvent('initial-sync', `attempting initial sync to ${hostTrackId} (hostPlaying=${hostPlaying})`); const ok = await syncToHost(hostTrackId, state); pushOrbitEvent('initial-sync', `result: ${ok ? 'success' : 'failed (will retry)'}`); if (ok) lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying }; } else { pushOrbitEvent('initial-sync', 'host has no current track yet, anchor only'); lastAppliedRef.current = { trackId: null, isPlaying: hostPlaying }; } } else if (currentLast.trackId !== hostTrackId) { // Distinguish "user manually paused" (true divergence) from "track // ended naturally" (NOT divergence — guest just needs the host's // next track loaded). Both leave `player.isPlaying === false`, but // `handleAudioEnded` keeps `currentTrack` pinned to the just-ended // track and resets `currentTime` to 0; a manual pause leaves // `currentTime` somewhere mid-track. The 0-position discriminator // separates them. const naturalEnd = !player.isPlaying && player.currentTrack?.id === currentLast.trackId && (player.currentTime ?? 0) < 0.5; const diverged = !naturalEnd && player.isPlaying !== currentLast.isPlaying; if (diverged) { // Guest is running their own show (typically: paused while host // kept going). Do not load/start the host's new track — just // track the host state so the catch-up prompt stays accurate. pushOrbitEvent('track-change', `host: ${currentLast.trackId} → ${hostTrackId} BUT guest diverged (player.isPlaying=${player.isPlaying} ≠ last.isPlaying=${currentLast.isPlaying}) — NOT loading new track`); lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying }; } else if (hostTrackId) { pushOrbitEvent('track-change', `host: ${currentLast.trackId} → ${hostTrackId}, guest in sync, following`); void syncToHost(hostTrackId, state); lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying }; } else { pushOrbitEvent('track-change', `host cleared current track, pausing guest`); if (player.isPlaying) player.pause(); lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying }; } } else if (currentLast.isPlaying !== hostPlaying) { // Only mirror when the guest hasn't diverged. We compare against the // *last applied* host state, not the new one — divergence means the // local player no longer matches what we last pushed in. const localMatchesLast = player.isPlaying === currentLast.isPlaying; pushOrbitEvent('play-pause-flip', `host: ${currentLast.isPlaying} → ${hostPlaying}, guest matches last=${localMatchesLast} (will ${localMatchesLast ? 'mirror' : 'skip'})`); if (localMatchesLast) { if (hostPlaying) player.resume(); else player.pause(); } // Either way, advance the anchor so we don't keep retrying the same // flip every tick. lastAppliedRef.current = { trackId: currentLast.trackId, isPlaying: hostPlaying }; } }; // 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); // Leaving / session ended → give the user their own transition prefs back. restoreGuestTransitions(); resetPendingResendState(); }; // outboxPlaylistId is read inside the tick loop at call time; the loop is // intentionally (re)started only on session activation / playlist change, not // when the outbox id updates mid-session. // eslint-disable-next-line react-hooks/exhaustive-deps }, [active, sessionPlaylistId]); // Outbox heartbeat — shared with the host hook; the guest's outbox is keyed // by its own active-server username. useOrbitOutboxHeartbeat(active, outboxPlaylistId, sessionId, myName); }