fix spotify playback

This commit is contained in:
Daniel Bulant 2026-05-24 18:18:54 +02:00
parent 2ca3de3c75
commit 96286eb424
No known key found for this signature in database
5 changed files with 116 additions and 8 deletions

View file

@ -20,6 +20,11 @@ export const defaultSdk = SpotifyApi.withClientCredentials(
); );
export const auth = betterAuth({ export const auth = betterAuth({
account: {
accountLinking: {
trustedProviders: ["spotify"],
},
},
user: { user: {
additionalFields: { additionalFields: {
lastSyncAt: { lastSyncAt: {
@ -40,6 +45,7 @@ export const auth = betterAuth({
scope: [ scope: [
"streaming", "streaming",
"user-read-playback-state", "user-read-playback-state",
"user-read-private",
"user-read-currently-playing", "user-read-currently-playing",
"user-modify-playback-state", "user-modify-playback-state",
"playlist-read-private", "playlist-read-private",
@ -48,7 +54,6 @@ export const auth = betterAuth({
"user-top-read", "user-top-read",
"user-read-recently-played", "user-read-recently-played",
"user-library-read", "user-library-read",
// "user-personalized",
"user-read-email", "user-read-email",
], ],
}, },

View file

@ -1,6 +1,15 @@
import { and, eq } from "drizzle-orm";
import Elysia from "elysia"; import Elysia from "elysia";
import { auth, betterAuthElysia } from "../auth"; import { auth, betterAuthElysia } from "../auth";
import { db } from "../db";
import { account } from "../db/schema";
const SPOTIFY_PLAYBACK_SCOPES = ["streaming", "user-read-private"];
function parseScopes(scope: string | null | undefined) {
return new Set(scope?.split(/[\s,]+/).filter(Boolean) ?? []);
}
export const spotifyRoutes = new Elysia() export const spotifyRoutes = new Elysia()
.use(betterAuthElysia) .use(betterAuthElysia)
@ -8,6 +17,28 @@ export const spotifyRoutes = new Elysia()
app.get( app.get(
"/token", "/token",
async ({ user, set }) => { async ({ user, set }) => {
const [spotifyAccount] = await db
.select({ scope: account.scope })
.from(account)
.where(
and(eq(account.userId, user.id), eq(account.providerId, "spotify")),
)
.limit(1);
const grantedScopes = parseScopes(spotifyAccount?.scope);
const missingScopes = SPOTIFY_PLAYBACK_SCOPES.filter(
(scope) => !grantedScopes.has(scope),
);
if (missingScopes.length > 0) {
set.status = 403;
return {
error: "Spotify playback permission required",
code: "SPOTIFY_RELINK_REQUIRED",
missingScopes,
};
}
const token = await auth.api.getAccessToken({ const token = await auth.api.getAccessToken({
body: { body: {
userId: user.id, userId: user.id,

View file

@ -20,8 +20,12 @@ import { Slider } from "#/components/ui/slider";
import { Spinner } from "#/components/ui/spinner"; import { Spinner } from "#/components/ui/spinner";
import { Switch } from "#/components/ui/switch"; import { Switch } from "#/components/ui/switch";
import { useParty } from "#/hooks/use-party"; import { useParty } from "#/hooks/use-party";
import { useSpotifyPlayer } from "#/hooks/use-spotify-player"; import {
SPOTIFY_PLAYBACK_SCOPES,
useSpotifyPlayer,
} from "#/hooks/use-spotify-player";
import { useUser } from "#/hooks/user"; import { useUser } from "#/hooks/user";
import { authClient } from "#/lib/auth-client";
import { client } from "#/lib/eden"; import { client } from "#/lib/eden";
type PartyQuestion = NonNullable< type PartyQuestion = NonNullable<
@ -56,6 +60,7 @@ export function Question() {
const [selected, setSelected] = useState<number | null>(null); const [selected, setSelected] = useState<number | null>(null);
const [selectedValue, setSelectedValue] = useState<number | null>(null); const [selectedValue, setSelectedValue] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isRelinkingSpotify, setIsRelinkingSpotify] = useState(false);
const question = party?.data?.currentQuestion; const question = party?.data?.currentQuestion;
const spotifyTrackUri = const spotifyTrackUri =
question?.song?.platform === "spotify" && question.song.platform_id question?.song?.platform === "spotify" && question.song.platform_id
@ -66,6 +71,7 @@ export function Question() {
setEnabled: setSpotifyEnabled, setEnabled: setSpotifyEnabled,
status: spotifyStatus, status: spotifyStatus,
error: spotifyError, error: spotifyError,
requiresRelink: spotifyRequiresRelink,
isLoading: spotifyIsLoading, isLoading: spotifyIsLoading,
} = useSpotifyPlayer(spotifyTrackUri); } = useSpotifyPlayer(spotifyTrackUri);
const questionStartTimestamp = question?.startTimestamp ?? null; const questionStartTimestamp = question?.startTimestamp ?? null;
@ -162,6 +168,18 @@ export function Question() {
} }
} }
async function handleSpotifyRelink() {
setIsRelinkingSpotify(true);
try {
await authClient.linkSocial({
provider: "spotify",
// scopes: [...SPOTIFY_PLAYBACK_SCOPES],
});
} finally {
setIsRelinkingSpotify(false);
}
}
return ( return (
<Section> <Section>
<SectionTitle>Question {party.data.questionIndex + 1}</SectionTitle> <SectionTitle>Question {party.data.questionIndex + 1}</SectionTitle>
@ -214,6 +232,19 @@ export function Question() {
: spotifyStatus === "loading" : spotifyStatus === "loading"
? " Connecting Spotify..." ? " Connecting Spotify..."
: null} : null}
{spotifyRequiresRelink ? (
<div className="mt-3">
<Button
size="sm"
onClick={() => void handleSpotifyRelink()}
disabled={isRelinkingSpotify}
>
{isRelinkingSpotify
? "Opening Spotify..."
: "Grant Spotify playback permission"}
</Button>
</div>
) : null}
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>

View file

@ -3,6 +3,29 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { client } from "#/lib/eden"; import { client } from "#/lib/eden";
const SPOTIFY_SDK_SRC = "https://sdk.scdn.co/spotify-player.js"; const SPOTIFY_SDK_SRC = "https://sdk.scdn.co/spotify-player.js";
export const SPOTIFY_PLAYBACK_SCOPES = ["streaming"] as const;
class SpotifyRelinkRequiredError extends Error {
constructor() {
super("Spotify needs playback permission before audio can start.");
this.name = "SpotifyRelinkRequiredError";
}
}
function isRelinkRequiredResponse(
value: unknown,
): value is { code: "SPOTIFY_RELINK_REQUIRED" } {
return (
typeof value === "object" &&
value !== null &&
"code" in value &&
value.code === "SPOTIFY_RELINK_REQUIRED"
);
}
function isRelinkRequiredError(error: unknown) {
return error instanceof SpotifyRelinkRequiredError;
}
let sdkPromise: Promise<void> | null = null; let sdkPromise: Promise<void> | null = null;
@ -37,7 +60,13 @@ function loadSpotifySdk(): Promise<void> {
async function fetchSpotifyAccessToken(): Promise<string> { async function fetchSpotifyAccessToken(): Promise<string> {
const { data, error } = await client.api.spotify.token.get(); const { data, error } = await client.api.spotify.token.get();
if (error || !data || !("accessToken" in data)) { if (error) {
if (isRelinkRequiredResponse(error.value)) {
throw new SpotifyRelinkRequiredError();
}
throw new Error("Spotify access token is unavailable.");
}
if (!data || !("accessToken" in data)) {
throw new Error("Spotify access token is unavailable."); throw new Error("Spotify access token is unavailable.");
} }
if (!data.accessToken) { if (!data.accessToken) {
@ -63,6 +92,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
const [enabled, setEnabledState] = useState(false); const [enabled, setEnabledState] = useState(false);
const [status, setStatus] = useState<PlaybackStatus>("idle"); const [status, setStatus] = useState<PlaybackStatus>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [requiresRelink, setRequiresRelink] = useState(false);
const playerRef = useRef<SpotifyPlayer | null>(null); const playerRef = useRef<SpotifyPlayer | null>(null);
const playerPromiseRef = useRef<Promise<SpotifyPlayer> | null>(null); const playerPromiseRef = useRef<Promise<SpotifyPlayer> | null>(null);
@ -86,6 +116,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
if (!silent) { if (!silent) {
setStatus("loading"); setStatus("loading");
setError(null); setError(null);
setRequiresRelink(false);
} }
playerPromiseRef.current = (async () => { playerPromiseRef.current = (async () => {
@ -125,6 +156,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
} catch (error_) { } catch (error_) {
const message = parseSpotifyError(error_); const message = parseSpotifyError(error_);
fail(message); fail(message);
setRequiresRelink(isRelinkRequiredError(error_));
setError(message); setError(message);
setStatus("error"); setStatus("error");
cb(""); cb("");
@ -242,6 +274,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
); );
} }
setRequiresRelink(false);
setStatus("playing"); setStatus("playing");
}, },
[ensurePlayer], [ensurePlayer],
@ -252,6 +285,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
enabledRef.current = next; enabledRef.current = next;
setEnabledState(next); setEnabledState(next);
setError(null); setError(null);
setRequiresRelink(false);
if (!next) { if (!next) {
setStatus("paused"); setStatus("paused");
@ -270,6 +304,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
setStatus("ready"); setStatus("ready");
} }
} catch (error_) { } catch (error_) {
setRequiresRelink(isRelinkRequiredError(error_));
setError(parseSpotifyError(error_)); setError(parseSpotifyError(error_));
setStatus("error"); setStatus("error");
} }
@ -288,6 +323,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
try { try {
await playTrack(trackUri); await playTrack(trackUri);
} catch (error_) { } catch (error_) {
setRequiresRelink(isRelinkRequiredError(error_));
setError(parseSpotifyError(error_)); setError(parseSpotifyError(error_));
setStatus("error"); setStatus("error");
} }
@ -308,6 +344,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
setEnabled, setEnabled,
status, status,
error, error,
requiresRelink,
isLoading: status === "loading", isLoading: status === "loading",
isReady: status === "ready" || status === "playing", isReady: status === "ready" || status === "playing",
}; };

View file

@ -1,8 +1,12 @@
import { treaty } from "@elysiajs/eden"; import { treaty } from "@elysiajs/eden";
import type { App } from "../../../api/src/index"; import type { App } from "../../../api/src/index";
export const client = treaty<App>("aura.rpi1.danbulant.cloud", {}); const apiBaseUrl =
// export const client = treaty<App>( import.meta.env.VITE_BETTER_AUTH_URL ||
// process.env.VITE_BETTER_AUTH_URL || "127.0.0.1:3000", (typeof window === "undefined"
// {}, ? "http://127.0.0.1:3000"
// ); : window.location.origin);
export const client = treaty<App>(apiBaseUrl, {
fetch: { credentials: "include" },
});