spotify playback attempt

This commit is contained in:
Daniel Bulant 2026-05-14 00:25:55 +02:00
parent 22111b2b30
commit 48938a18cc
No known key found for this signature in database
5 changed files with 384 additions and 6 deletions

View file

@ -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 })),

32
api/src/routes/spotify.ts Normal file
View file

@ -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 },
),
);

View file

@ -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<number | null>(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 (
<Section>
<SectionTitle>Preparing quiz...</SectionTitle>
</Section>
);
if (!question)
return (
<Section>
<SectionTitle>Preparing quiz...</SectionTitle>
</Section>
);
const partyId = party.id;
const timeLeft = formatTimeLeft(question.endTimestamp - now);
@ -133,6 +147,28 @@ export function Question() {
</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemContent>
<ItemHeader>
<Label htmlFor="spotify-playback" className="cursor-pointer">
Spotify playback
</Label>
<Switch
id="spotify-playback"
checked={spotifyEnabled}
onCheckedChange={(checked) =>
void setSpotifyEnabled(checked === true)
}
/>
</ItemHeader>
<ItemDescription>
{question.song?.name ?? "This question has no associated song."}
{spotifyStatus === "playing" ? " Playing now." : null}
{spotifyStatus === "loading" ? " Connecting Spotify..." : null}
{spotifyError ? ` ${spotifyError}` : null}
</ItemDescription>
</ItemContent>
</Item>
{question.type === "numeric" ? (
<Item variant="muted">
<ItemContent>
@ -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),
)
}
/>
<div className="text-sm text-muted-foreground">
Exact value:{" "}

View file

@ -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<void> | null = null;
function loadSpotifySdk(): Promise<void> {
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<string> {
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<PlaybackStatus>("idle");
const [error, setError] = useState<string | null>(null);
const playerRef = useRef<SpotifyPlayer | null>(null);
const deviceIdRef = useRef<string | null>(null);
const trackUriRef = useRef<string | null | undefined>(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<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 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",
};
}

51
web/src/types/spotify.d.ts vendored Normal file
View file

@ -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<boolean>;
disconnect(): void;
pause(): Promise<void>;
resume(): Promise<void>;
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";
}