first attempt at spotify playback

This commit is contained in:
Daniel Bulant 2026-05-16 14:57:01 +02:00
parent dd02db9011
commit 032e656297
No known key found for this signature in database
8 changed files with 204 additions and 87 deletions

View file

@ -38,6 +38,7 @@ export const auth = betterAuth({
clientId: SPOTIFY_CLIENT_ID,
clientSecret: SPOTIFY_CLIENT_SECRET,
scope: [
"streaming",
"user-read-playback-state",
"user-read-currently-playing",
"user-modify-playback-state",

View file

@ -61,8 +61,13 @@ export function Question() {
question?.song?.platform === "spotify" && question.song.platform_id
? `spotify:track:${question.song.platform_id}`
: null;
const { enabled: spotifyEnabled, setEnabled: setSpotifyEnabled } =
useSpotifyPlayer(spotifyTrackUri);
const {
enabled: spotifyEnabled,
setEnabled: setSpotifyEnabled,
status: spotifyStatus,
error: spotifyError,
isLoading: spotifyIsLoading,
} = useSpotifyPlayer(spotifyTrackUri);
const questionStartTimestamp = question?.startTimestamp ?? null;
const questionAnnouncement = question
? getQuestionAnnouncement(question)
@ -191,6 +196,7 @@ export function Question() {
<Switch
id="spotify-playback"
checked={spotifyEnabled}
disabled={spotifyIsLoading || !spotifyTrackUri}
onCheckedChange={(checked) =>
void setSpotifyEnabled(checked === true)
}
@ -201,6 +207,13 @@ export function Question() {
? "Listen closely and guess the song."
: (question.song?.name ??
"This question has no associated song.")}
{!spotifyTrackUri
? " Spotify playback is unavailable for this question."
: spotifyError
? ` ${spotifyError}`
: spotifyStatus === "loading"
? " Connecting Spotify..."
: null}
</ItemDescription>
</ItemContent>
</Item>

View file

@ -65,6 +65,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
const [error, setError] = useState<string | null>(null);
const playerRef = useRef<SpotifyPlayer | null>(null);
const playerPromiseRef = useRef<Promise<SpotifyPlayer> | null>(null);
const deviceIdRef = useRef<string | null>(null);
const trackUriRef = useRef<string | null | undefined>(trackUri);
const enabledRef = useRef(enabled);
@ -77,6 +78,130 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
trackUriRef.current = trackUri;
}, [trackUri]);
const ensurePlayer = useCallback(async (options?: { silent?: boolean }) => {
if (playerRef.current) return playerRef.current;
if (playerPromiseRef.current) return playerPromiseRef.current;
const silent = options?.silent === true;
if (!silent) {
setStatus("loading");
setError(null);
}
playerPromiseRef.current = (async () => {
await loadSpotifySdk();
if (!window.Spotify) {
throw new Error("Spotify playback SDK did not initialize.");
}
let settled = false;
let readyTimeoutId: number | null = null;
let resolveReady: ((deviceId: string) => void) | null = null;
let rejectReady: ((error: Error) => void) | null = null;
const readyPromise = new Promise<string>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
readyTimeoutId = window.setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error("Timed out waiting for Spotify playback device."));
}, 15_000);
});
const fail = (message: string) => {
if (settled) return;
settled = true;
if (readyTimeoutId) window.clearTimeout(readyTimeoutId);
rejectReady?.(new Error(message));
};
const player = new window.Spotify.Player({
name: "ITPDP Quiz",
volume: 0.8,
getOAuthToken: async (cb) => {
try {
cb(await fetchSpotifyAccessToken());
} catch (error_) {
const message = parseSpotifyError(error_);
fail(message);
setError(message);
setStatus("error");
cb("");
}
},
});
player.addListener("ready", ({ device_id }) => {
deviceIdRef.current = device_id;
if (readyTimeoutId) window.clearTimeout(readyTimeoutId);
settled = true;
resolveReady?.(device_id);
setStatus("ready");
});
player.addListener("not_ready", ({ device_id }) => {
if (deviceIdRef.current === device_id) {
deviceIdRef.current = null;
}
setStatus(enabledRef.current ? "loading" : "paused");
});
player.addListener("initialization_error", ({ message }) => {
fail(message);
setError(message);
setStatus("error");
});
player.addListener("authentication_error", ({ message }) => {
fail(message);
setError(message);
setStatus("error");
});
player.addListener("account_error", ({ message }) => {
fail(message);
setError(message);
setStatus("error");
});
player.addListener("playback_error", ({ message }) => {
fail(message);
setError(message);
setStatus("error");
});
player.addListener("autoplay_failed", () => {
setError(
"Spotify playback was blocked by the browser autoplay policy.",
);
setStatus("error");
});
const connected = await player.connect();
if (!connected) {
throw new Error("Unable to connect to Spotify playback.");
}
playerRef.current = player;
const deviceId = await readyPromise;
deviceIdRef.current = deviceId;
return player;
})()
.catch((error_: unknown) => {
playerRef.current?.disconnect();
playerRef.current = null;
deviceIdRef.current = null;
throw error_;
})
.finally(() => {
playerPromiseRef.current = null;
});
return playerPromiseRef.current;
}, []);
useEffect(() => {
if (!trackUri || playerRef.current || playerPromiseRef.current) return;
void ensurePlayer({ silent: true }).catch(() => {
// Ignore preload failures; explicit enable will surface them.
});
}, [trackUri, ensurePlayer]);
const pause = useCallback(async () => {
const player = playerRef.current;
if (!player) return;
@ -87,83 +212,17 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
}
}, []);
const ensurePlayer = useCallback(async () => {
if (playerRef.current) return playerRef.current;
setStatus("loading");
setError(null);
await loadSpotifySdk();
if (!window.Spotify) {
throw new Error("Spotify playback SDK did not initialize.");
}
let resolveReady: ((deviceId: string) => void) | null = null;
const readyPromise = new Promise<string>((resolve) => {
resolveReady = resolve;
});
const player = new window.Spotify.Player({
name: "ITPDP Quiz",
volume: 0.8,
getOAuthToken: async (cb) => {
try {
cb(await fetchSpotifyAccessToken());
} catch (error_) {
setError(parseSpotifyError(error_));
setStatus("error");
cb("");
}
},
});
player.addListener("ready", ({ device_id }) => {
deviceIdRef.current = device_id;
resolveReady?.(device_id);
setStatus("ready");
});
player.addListener("not_ready", ({ device_id }) => {
if (deviceIdRef.current === device_id) {
deviceIdRef.current = null;
}
setStatus(enabledRef.current ? "loading" : "paused");
});
player.addListener("initialization_error", ({ message }) => {
setError(message);
setStatus("error");
});
player.addListener("authentication_error", ({ message }) => {
setError(message);
setStatus("error");
});
player.addListener("account_error", ({ message }) => {
setError(message);
setStatus("error");
});
player.addListener("playback_error", ({ message }) => {
setError(message);
setStatus("error");
});
const connected = await player.connect();
if (!connected) {
throw new Error("Unable to connect to Spotify playback.");
}
playerRef.current = player;
await readyPromise;
return player;
}, []);
const playTrack = useCallback(
async (uri: string) => {
if (!uri) return;
const player = await ensurePlayer();
const player = playerRef.current ?? (await ensurePlayer());
const deviceId = deviceIdRef.current;
if (!player || !deviceId) {
throw new Error("Spotify playback device is not ready.");
}
await player.activateElement();
const accessToken = await fetchSpotifyAccessToken();
const response = await fetch(
`https://api.spotify.com/v1/me/player/play?device_id=${encodeURIComponent(deviceId)}`,
@ -201,7 +260,9 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
}
try {
await ensurePlayer();
if (!playerRef.current) {
await ensurePlayer();
}
if (trackUriRef.current) {
await playTrack(trackUriRef.current);
}

View file

@ -1,4 +1,3 @@
import { Link } from "@tanstack/react-router";
import { authClient } from "#/lib/auth-client";
export default function BetterAuthHeader() {
@ -22,24 +21,22 @@ export default function BetterAuthHeader() {
</span>
</div>
)}
<button
onClick={() => {
void authClient.signOut();
}}
className="flex-1 h-9 px-4 text-sm font-medium bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-50 border border-neutral-300 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
<a
href="/logout"
className="flex-1 inline-flex h-9 items-center justify-center px-4 text-sm font-medium bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-50 border border-neutral-300 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
>
Sign out
</button>
</a>
</div>
);
}
return (
<Link
to="/demo/better-auth"
<a
href="/demo/better-auth"
className="h-9 px-4 text-sm font-medium bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-50 border border-neutral-300 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors inline-flex items-center"
>
Sign in
</Link>
</a>
);
}

View file

@ -9,9 +9,15 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as LogoutRouteImport } from './routes/logout'
import { Route as LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index'
const LogoutRoute = LogoutRouteImport.update({
id: '/logout',
path: '/logout',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@ -26,31 +32,42 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/logout': typeof LogoutRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/logout': typeof LogoutRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/logout': typeof LogoutRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/login'
fullPaths: '/' | '/login' | '/logout'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/login'
id: '__root__' | '/' | '/login'
to: '/' | '/login' | '/logout'
id: '__root__' | '/' | '/login' | '/logout'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute
LogoutRoute: typeof LogoutRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/logout': {
id: '/logout'
path: '/logout'
fullPath: '/logout'
preLoaderRoute: typeof LogoutRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@ -71,6 +88,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LoginRoute: LoginRoute,
LogoutRoute: LogoutRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View file

@ -52,7 +52,7 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
}),
shellComponent: RootDocument,
beforeLoad: async ({ context, location }) => {
const authPublicPaths = new Set(["/login"]);
const authPublicPaths = new Set(["/login", "/logout"]);
const isAuthPublicPath = authPublicPaths.has(location.pathname);
let session: AuthSession | null;
if (typeof window === "undefined") {

24
web/src/routes/logout.tsx Normal file
View file

@ -0,0 +1,24 @@
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { useEffect } from "react";
import { signOutAndClearQueryCache } from "#/lib/auth-client";
export const Route = createFileRoute("/logout")({
component: LogoutRoute,
});
function LogoutRoute() {
const { queryClient } = Route.useRouteContext();
const router = useRouter();
useEffect(() => {
void signOutAndClearQueryCache({
queryClient,
navigateToLogin: () => router.navigate({ to: "/login", replace: true }),
});
}, [queryClient, router]);
return (
<div className="p-6 text-sm text-muted-foreground">Signing out...</div>
);
}

View file

@ -39,6 +39,8 @@ declare global {
| "playback_error",
callback: (payload: { message: string }) => void,
): void;
addListener(event: "autoplay_failed", callback: () => void): void;
activateElement(): Promise<void>;
}
type SpotifyPlayerEvent =
@ -47,5 +49,6 @@ declare global {
| "initialization_error"
| "authentication_error"
| "account_error"
| "playback_error";
| "playback_error"
| "autoplay_failed";
}