import { useEffect, useRef } from 'react'; import type { Activity } from '@backspace/shared'; import { api } from '../api/client'; import { useActivityStore } from '../stores/activityStore'; import { setServerTime } from '../utils/serverTime'; /** Ceiling between checks while a track is playing. */ const POLL_CONNECTED_MS = 20_000; /** While the account is not linked — cheap heartbeat that notices a new link. */ const POLL_IDLE_MS = 60_000; /** Never hammer the API, however close the track end looks. */ const MIN_POLL_MS = 4_000; /** * How long a silent answer is tolerated before the block is taken down. * * Spotify reports "nothing playing" in the gap between two songs, so clearing * on the first empty answer made the block vanish and reappear between every * track. */ const EMPTY_GRACE_MS = 25_000; function trackKey(activity: Activity | null): string { return activity ? `${activity.details ?? ''}|${activity.state ?? ''}` : ''; } /** * Publishes what the user is listening to on Spotify as an activity. * * The browser never sees a Spotify token: it asks this instance, which holds * the credentials and talks to Spotify. Reported under its own source so it * coexists with the desktop game detector instead of replacing it. */ export function useSpotifyActivity(): void { const showActivity = useActivityStore((s) => s.showActivity); const lastKeyRef = useRef(''); const emptySinceRef = useRef(0); useEffect(() => { const setSource = useActivityStore.getState().setSourceActivities; // The privacy toggle governs this like any other activity source. if (!showActivity) { setSource('spotify', []); return; } let cancelled = false; let timer: ReturnType | undefined; const tick = async () => { let delay = POLL_IDLE_MS; try { // Polling a hidden tab burns Spotify's rate limit for a screen nobody // is looking at; the next visible tick catches up. if (typeof document === 'undefined' || !document.hidden) { const { activity, connected, serverTime } = await api.spotify.nowPlaying(); if (cancelled) return; if (serverTime) setServerTime(serverTime); if (activity) { emptySinceRef.current = 0; const key = trackKey(activity); // A new track goes out at once; progress-only updates can wait for // the debounce, which is what it is there for. const immediate = key !== lastKeyRef.current; lastKeyRef.current = key; setSource('spotify', [activity], { immediate }); // Check back just after this track should end, rather than landing // mid-song and showing everyone the previous one for another // twenty seconds. const end = activity.timestamps?.end; const remaining = end ? end - Date.now() + 1_000 : POLL_CONNECTED_MS; delay = Math.max(MIN_POLL_MS, Math.min(POLL_CONNECTED_MS, remaining)); } else if (connected) { const now = Date.now(); if (!emptySinceRef.current) emptySinceRef.current = now; if (now - emptySinceRef.current >= EMPTY_GRACE_MS) { lastKeyRef.current = ''; setSource('spotify', []); } delay = MIN_POLL_MS; } else { lastKeyRef.current = ''; emptySinceRef.current = 0; setSource('spotify', []); delay = POLL_IDLE_MS; } } else { delay = POLL_CONNECTED_MS; } } catch { // Network hiccup or a logged-out session: keep the last known state and // retry, rather than reporting "stopped listening" on a transient error. delay = POLL_CONNECTED_MS; } if (!cancelled) timer = setTimeout(() => void tick(), delay); }; void tick(); return () => { cancelled = true; if (timer) clearTimeout(timer); setSource('spotify', []); }; }, [showActivity]); }