Soundboard: naming a clip used window.prompt, which Electron does not implement — it returned nothing, the flow aborted in silence, and adding a sound worked in the browser while doing nothing at all in the desktop app. Replaced with a two-step field inside the popover, identical in both. Spotify, three separate defects behind the two symptoms reported: Out of sync — a 20s poll stacked on the activity store's 5s debounce left everyone else on the previous track for up to 25s. The next poll is now scheduled just past the current track's end instead of on a fixed interval, and a track change bypasses the debounce (it happens once every few minutes; the debounce exists for chatty producers). Vanishing — a paused track, and the silent gap Spotify reports between two songs, both cleared the activity outright. Pausing is now carried as state rather than absence, and an empty answer is tolerated for 25s before the block comes down. Progress bar — timestamps are computed with the server's clock and were drawn against the viewer's, so any drift displaced the bar; and it kept advancing after a pause until the next poll. The ready payload now carries server time so each client can correct its own offset, and the bar freezes when paused. Tray, native notifications and system audio in screen share were all found already implemented and wired end to end; recorded in the roadmap rather than built again.
109 lines
4.0 KiB
TypeScript
109 lines
4.0 KiB
TypeScript
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<typeof setTimeout> | 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]);
|
|
}
|