fix spotify playback
This commit is contained in:
parent
2ca3de3c75
commit
96286eb424
5 changed files with 116 additions and 8 deletions
|
|
@ -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",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue