feat(spotify): show the current track as an activity
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s

OAuth Authorization Code flow, with tokens kept server-side: refreshing needs
the client secret, so the browser never holds a Spotify token — it asks this
instance what is playing and this instance calls Spotify.

The callback arrives as a plain browser redirect with no Authorization header,
so the OAuth state carries the user id signed with the instance secret and is
compared in constant time; without that, anyone could bind their Spotify
account to another user.

Activities are now tracked per producer. pushActivities replaced the whole
list, so the desktop game detector and Spotify would erase each other — losing
exactly the case this is for, a game and Spotify at once.

Polling backs off when the tab is hidden and keeps the last known track on a
network error rather than reporting 'stopped listening'. A rejected refresh
token (access revoked on Spotify's side) drops the row so the UI stops
claiming a live connection.

Scope is read-only: user-read-currently-playing and user-read-playback-state.

Per the fork's language rule, the new UI ships in en and pt-BR, and this
round also translates the privacy panel.
This commit is contained in:
2026-08-31 12:37:08 -03:00
parent c022f2795f
commit 75316b0882
16 changed files with 4413 additions and 12 deletions
@@ -0,0 +1,60 @@
import { useEffect } from 'react';
import { api } from '../api/client';
import { useActivityStore } from '../stores/activityStore';
/** While a track is playing. Short enough that a track change shows up quickly. */
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;
/**
* 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);
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 } = await api.spotify.nowPlaying();
if (cancelled) return;
setSource('spotify', activity ? [activity] : []);
delay = connected ? POLL_CONNECTED_MS : 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.
}
if (!cancelled) timer = setTimeout(() => void tick(), delay);
};
void tick();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
setSource('spotify', []);
};
}, [showActivity]);
}