diff --git a/api/src/index.ts b/api/src/index.ts index a195b48..5cde96d 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -10,6 +10,7 @@ import { deviceClaimApp, deviceSocketApp } from "./routes/device-socket.ts"; import { partyApp } from "./routes/party"; import { partySocketApp, pubsub } from "./routes/party-socket"; import { quizRoutes } from "./routes/quiz.ts"; +import { spotifyRoutes } from "./routes/spotify"; import { statsApp } from "./routes/stats.ts"; const app = new Elysia() @@ -22,6 +23,7 @@ const app = new Elysia() .use(partyAnalysisApp) .use(partySocketApp) .use(deviceClaimApp) + .use(spotifyRoutes) .use(quizRoutes) .use(deviceSocketApp) .get("/", () => ({ ok: true })), diff --git a/api/src/routes/spotify.ts b/api/src/routes/spotify.ts new file mode 100644 index 0000000..f82dfa0 --- /dev/null +++ b/api/src/routes/spotify.ts @@ -0,0 +1,32 @@ +import Elysia from "elysia"; + +import { auth, betterAuthElysia } from "../auth"; + +export const spotifyRoutes = new Elysia() + .use(betterAuthElysia) + .group("/spotify", (app) => + app.get( + "/token", + async ({ user, set }) => { + const token = await auth.api.getAccessToken({ + body: { + userId: user.id, + providerId: "spotify", + }, + }); + + if (!token?.accessToken) { + set.status = 404; + return { error: "Spotify access token not found" }; + } + + return { + accessToken: token.accessToken, + expiresAt: token.accessTokenExpiresAt + ? Number(token.accessTokenExpiresAt) + : null, + }; + }, + { auth: true }, + ), + ); diff --git a/web/src/components/party/question.tsx b/web/src/components/party/question.tsx index 9de88d9..22830e2 100644 --- a/web/src/components/party/question.tsx +++ b/web/src/components/party/question.tsx @@ -9,6 +9,7 @@ import { ItemHeader, ItemTitle, } from "#/components/ui/item"; +import { Label } from "#/components/ui/label"; import { Progress, ProgressLabel, @@ -17,7 +18,9 @@ import { import { Section, SectionTitle } from "#/components/ui/section"; import { Slider } from "#/components/ui/slider"; import { Spinner } from "#/components/ui/spinner"; +import { Switch } from "#/components/ui/switch"; import { useParty } from "#/hooks/use-party"; +import { useSpotifyPlayer } from "#/hooks/use-spotify-player"; import { useUser } from "#/hooks/user"; import { client } from "#/lib/eden"; @@ -39,6 +42,16 @@ export function Question() { const [selectedValue, setSelectedValue] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const question = party?.data?.currentQuestion; + const spotifyTrackUri = + question?.song?.platform === "spotify" && question.song.platform_id + ? `spotify:track:${question.song.platform_id}` + : null; + const { + enabled: spotifyEnabled, + setEnabled: setSpotifyEnabled, + status: spotifyStatus, + error: spotifyError, + } = useSpotifyPlayer(spotifyTrackUri); useEffect(() => { const timer = window.setInterval(() => { @@ -54,11 +67,12 @@ export function Question() { setSelectedValue(question.type === "numeric" ? question.range.min : null); }, [question]); - if (!question) return ( -
- Preparing quiz... -
- ); + if (!question) + return ( +
+ Preparing quiz... +
+ ); const partyId = party.id; const timeLeft = formatTimeLeft(question.endTimestamp - now); @@ -133,6 +147,28 @@ export function Question() { + + + + + + void setSpotifyEnabled(checked === true) + } + /> + + + {question.song?.name ?? "This question has no associated song."} + {spotifyStatus === "playing" ? " Playing now." : null} + {spotifyStatus === "loading" ? " Connecting Spotify..." : null} + {spotifyError ? ` ${spotifyError}` : null} + + + {question.type === "numeric" ? ( @@ -146,7 +182,11 @@ export function Question() { ? [currentNumericSelection] : undefined } - onValueChange={(value) => setSelectedValue(typeof value === "number" ? value : value[0] ?? null)} + onValueChange={(value) => + setSelectedValue( + typeof value === "number" ? value : (value[0] ?? null), + ) + } />
Exact value:{" "} diff --git a/web/src/hooks/use-spotify-player.ts b/web/src/hooks/use-spotify-player.ts new file mode 100644 index 0000000..2d2c9b2 --- /dev/null +++ b/web/src/hooks/use-spotify-player.ts @@ -0,0 +1,253 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { client } from "#/lib/eden"; + +const SPOTIFY_SDK_SRC = "https://sdk.scdn.co/spotify-player.js"; + +let sdkPromise: Promise | null = null; + +function loadSpotifySdk(): Promise { + if (typeof window === "undefined") { + return Promise.reject( + new Error("Spotify playback is only available in the browser."), + ); + } + if (window.Spotify) return Promise.resolve(); + if (sdkPromise) return sdkPromise; + + sdkPromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = SPOTIFY_SDK_SRC; + script.async = true; + script.dataset.spotifyWebPlaybackSdk = "true"; + script.onload = () => { + if (window.Spotify) resolve(); + }; + script.onerror = () => + reject(new Error("Failed to load the Spotify playback SDK.")); + window.onSpotifyWebPlaybackSDKReady = () => resolve(); + document.head.appendChild(script); + }); + sdkPromise.catch(() => { + sdkPromise = null; + }); + + return sdkPromise; +} + +async function fetchSpotifyAccessToken(): Promise { + const { data, error } = await client.api.spotify.token.get(); + if (error || !data || !("accessToken" in data)) { + throw new Error("Spotify access token is unavailable."); + } + if (!data.accessToken) { + throw new Error("Spotify access token is unavailable."); + } + return data.accessToken; +} + +function parseSpotifyError(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return "Spotify playback failed."; +} + +type PlaybackStatus = + | "idle" + | "loading" + | "ready" + | "playing" + | "paused" + | "error"; + +export function useSpotifyPlayer(trackUri: string | null | undefined) { + const [enabled, setEnabledState] = useState(false); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + + const playerRef = useRef(null); + const deviceIdRef = useRef(null); + const trackUriRef = useRef(trackUri); + const enabledRef = useRef(enabled); + + useEffect(() => { + enabledRef.current = enabled; + }, [enabled]); + + useEffect(() => { + trackUriRef.current = trackUri; + }, [trackUri]); + + const pause = useCallback(async () => { + const player = playerRef.current; + if (!player) return; + try { + await player.pause(); + } catch { + // Ignore pause failures when the SDK is already detached. + } + }, []); + + 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 deviceId = deviceIdRef.current; + if (!player || !deviceId) { + throw new Error("Spotify playback device is not ready."); + } + + const accessToken = await fetchSpotifyAccessToken(); + const response = await fetch( + `https://api.spotify.com/v1/me/player/play?device_id=${encodeURIComponent(deviceId)}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ uris: [uri] }), + }, + ); + + if (!response.ok) { + throw new Error( + `Spotify playback request failed (${response.status}).`, + ); + } + + setStatus("playing"); + }, + [ensurePlayer], + ); + + const setEnabled = useCallback( + async (next: boolean) => { + enabledRef.current = next; + setEnabledState(next); + setError(null); + + if (!next) { + setStatus("paused"); + await pause(); + return; + } + + try { + await ensurePlayer(); + if (trackUriRef.current) { + await playTrack(trackUriRef.current); + } + if (!trackUriRef.current) { + setStatus("ready"); + } + } catch (error_) { + setError(parseSpotifyError(error_)); + setStatus("error"); + } + }, + [pause, playTrack, ensurePlayer], + ); + + useEffect(() => { + if (!enabledRef.current) return; + if (!trackUri) { + void pause(); + setStatus("ready"); + return; + } + void (async () => { + try { + await playTrack(trackUri); + } catch (error_) { + setError(parseSpotifyError(error_)); + setStatus("error"); + } + })(); + }, [pause, trackUri, playTrack]); + + useEffect(() => { + return () => { + void pause(); + playerRef.current?.disconnect(); + playerRef.current = null; + deviceIdRef.current = null; + }; + }, [pause]); + + return { + enabled, + setEnabled, + status, + error, + isLoading: status === "loading", + isReady: status === "ready" || status === "playing", + }; +} diff --git a/web/src/types/spotify.d.ts b/web/src/types/spotify.d.ts new file mode 100644 index 0000000..76803f6 --- /dev/null +++ b/web/src/types/spotify.d.ts @@ -0,0 +1,51 @@ +export {}; + +declare global { + interface Window { + onSpotifyWebPlaybackSDKReady?: (() => void) | null; + Spotify?: SpotifySdk; + } + + type SpotifyPlayer = SpotifyPlayerInstance; + + interface SpotifySdk { + Player: new (options: SpotifyPlayerOptions) => SpotifyPlayerInstance; + } + + interface SpotifyPlayerOptions { + name: string; + getOAuthToken: (cb: (token: string) => void) => void; + volume?: number; + } + + interface SpotifyPlayerInstance { + connect(): Promise; + disconnect(): void; + pause(): Promise; + resume(): Promise; + addListener( + event: "ready", + callback: (payload: { device_id: string }) => void, + ): void; + addListener( + event: "not_ready", + callback: (payload: { device_id: string }) => void, + ): void; + addListener( + event: + | "initialization_error" + | "authentication_error" + | "account_error" + | "playback_error", + callback: (payload: { message: string }) => void, + ): void; + } + + type SpotifyPlayerEvent = + | "ready" + | "not_ready" + | "initialization_error" + | "authentication_error" + | "account_error" + | "playback_error"; +}