spotify playback attempt
This commit is contained in:
parent
22111b2b30
commit
48938a18cc
5 changed files with 384 additions and 6 deletions
|
|
@ -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
32
api/src/routes/spotify.ts
Normal 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 },
|
||||
),
|
||||
);
|
||||
|
|
@ -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:{" "}
|
||||
|
|
|
|||
253
web/src/hooks/use-spotify-player.ts
Normal file
253
web/src/hooks/use-spotify-player.ts
Normal 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
51
web/src/types/spotify.d.ts
vendored
Normal 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";
|
||||
}
|
||||
Loading…
Reference in a new issue