diff --git a/api/src/auth.ts b/api/src/auth.ts index 706974f..f8aecbb 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -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", diff --git a/web/src/components/party/question.tsx b/web/src/components/party/question.tsx index d61e7f8..d6987d6 100644 --- a/web/src/components/party/question.tsx +++ b/web/src/components/party/question.tsx @@ -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() { 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} diff --git a/web/src/hooks/use-spotify-player.ts b/web/src/hooks/use-spotify-player.ts index 2d2c9b2..32f92f9 100644 --- a/web/src/hooks/use-spotify-player.ts +++ b/web/src/hooks/use-spotify-player.ts @@ -65,6 +65,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) { const [error, setError] = useState(null); const playerRef = useRef(null); + const playerPromiseRef = useRef | null>(null); const deviceIdRef = useRef(null); const trackUriRef = useRef(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((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((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); } diff --git a/web/src/integrations/better-auth/header-user.tsx b/web/src/integrations/better-auth/header-user.tsx index e7a58b4..6349789 100644 --- a/web/src/integrations/better-auth/header-user.tsx +++ b/web/src/integrations/better-auth/header-user.tsx @@ -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() { )} - + ); } return ( - Sign in - + ); } diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 4e935d0..db4ab32 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -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) diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 9555a19..60429ad 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -52,7 +52,7 @@ export const Route = createRootRouteWithContext()({ }), 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") { diff --git a/web/src/routes/logout.tsx b/web/src/routes/logout.tsx new file mode 100644 index 0000000..9bc7b6c --- /dev/null +++ b/web/src/routes/logout.tsx @@ -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 ( +
Signing out...
+ ); +} diff --git a/web/src/types/spotify.d.ts b/web/src/types/spotify.d.ts index 76803f6..d9a8167 100644 --- a/web/src/types/spotify.d.ts +++ b/web/src/types/spotify.d.ts @@ -39,6 +39,8 @@ declare global { | "playback_error", callback: (payload: { message: string }) => void, ): void; + addListener(event: "autoplay_failed", callback: () => void): void; + activateElement(): Promise; } type SpotifyPlayerEvent = @@ -47,5 +49,6 @@ declare global { | "initialization_error" | "authentication_error" | "account_error" - | "playback_error"; + | "playback_error" + | "autoplay_failed"; }