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
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:
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user