first attempt at spotify playback
This commit is contained in:
parent
dd02db9011
commit
032e656297
8 changed files with 204 additions and 87 deletions
|
|
@ -38,6 +38,7 @@ export const auth = betterAuth({
|
|||
clientId: SPOTIFY_CLIENT_ID,
|
||||
clientSecret: SPOTIFY_CLIENT_SECRET,
|
||||
scope: [
|
||||
"streaming",
|
||||
"user-read-playback-state",
|
||||
"user-read-currently-playing",
|
||||
"user-modify-playback-state",
|
||||
|
|
|
|||
|
|
@ -61,8 +61,13 @@ export function Question() {
|
|||
question?.song?.platform === "spotify" && question.song.platform_id
|
||||
? `spotify:track:${question.song.platform_id}`
|
||||
: null;
|
||||
const { enabled: spotifyEnabled, setEnabled: setSpotifyEnabled } =
|
||||
useSpotifyPlayer(spotifyTrackUri);
|
||||
const {
|
||||
enabled: spotifyEnabled,
|
||||
setEnabled: setSpotifyEnabled,
|
||||
status: spotifyStatus,
|
||||
error: spotifyError,
|
||||
isLoading: spotifyIsLoading,
|
||||
} = useSpotifyPlayer(spotifyTrackUri);
|
||||
const questionStartTimestamp = question?.startTimestamp ?? null;
|
||||
const questionAnnouncement = question
|
||||
? getQuestionAnnouncement(question)
|
||||
|
|
@ -191,6 +196,7 @@ export function Question() {
|
|||
<Switch
|
||||
id="spotify-playback"
|
||||
checked={spotifyEnabled}
|
||||
disabled={spotifyIsLoading || !spotifyTrackUri}
|
||||
onCheckedChange={(checked) =>
|
||||
void setSpotifyEnabled(checked === true)
|
||||
}
|
||||
|
|
@ -201,6 +207,13 @@ export function Question() {
|
|||
? "Listen closely and guess the song."
|
||||
: (question.song?.name ??
|
||||
"This question has no associated song.")}
|
||||
{!spotifyTrackUri
|
||||
? " Spotify playback is unavailable for this question."
|
||||
: spotifyError
|
||||
? ` ${spotifyError}`
|
||||
: spotifyStatus === "loading"
|
||||
? " Connecting Spotify..."
|
||||
: null}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const playerRef = useRef<SpotifyPlayer | null>(null);
|
||||
const playerPromiseRef = useRef<Promise<SpotifyPlayer> | null>(null);
|
||||
const deviceIdRef = useRef<string | null>(null);
|
||||
const trackUriRef = useRef<string | null | undefined>(trackUri);
|
||||
const enabledRef = useRef(enabled);
|
||||
|
|
@ -77,6 +78,130 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
|
|||
trackUriRef.current = trackUri;
|
||||
}, [trackUri]);
|
||||
|
||||
const ensurePlayer = useCallback(async (options?: { silent?: boolean }) => {
|
||||
if (playerRef.current) return playerRef.current;
|
||||
if (playerPromiseRef.current) return playerPromiseRef.current;
|
||||
|
||||
const silent = options?.silent === true;
|
||||
if (!silent) {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
playerPromiseRef.current = (async () => {
|
||||
await loadSpotifySdk();
|
||||
|
||||
if (!window.Spotify) {
|
||||
throw new Error("Spotify playback SDK did not initialize.");
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
let readyTimeoutId: number | null = null;
|
||||
let resolveReady: ((deviceId: string) => void) | null = null;
|
||||
let rejectReady: ((error: Error) => void) | null = null;
|
||||
const readyPromise = new Promise<string>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
readyTimeoutId = window.setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new Error("Timed out waiting for Spotify playback device."));
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
const fail = (message: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (readyTimeoutId) window.clearTimeout(readyTimeoutId);
|
||||
rejectReady?.(new Error(message));
|
||||
};
|
||||
|
||||
const player = new window.Spotify.Player({
|
||||
name: "ITPDP Quiz",
|
||||
volume: 0.8,
|
||||
getOAuthToken: async (cb) => {
|
||||
try {
|
||||
cb(await fetchSpotifyAccessToken());
|
||||
} catch (error_) {
|
||||
const message = parseSpotifyError(error_);
|
||||
fail(message);
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
cb("");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
player.addListener("ready", ({ device_id }) => {
|
||||
deviceIdRef.current = device_id;
|
||||
if (readyTimeoutId) window.clearTimeout(readyTimeoutId);
|
||||
settled = true;
|
||||
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 }) => {
|
||||
fail(message);
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
});
|
||||
player.addListener("authentication_error", ({ message }) => {
|
||||
fail(message);
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
});
|
||||
player.addListener("account_error", ({ message }) => {
|
||||
fail(message);
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
});
|
||||
player.addListener("playback_error", ({ message }) => {
|
||||
fail(message);
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
});
|
||||
player.addListener("autoplay_failed", () => {
|
||||
setError(
|
||||
"Spotify playback was blocked by the browser autoplay policy.",
|
||||
);
|
||||
setStatus("error");
|
||||
});
|
||||
|
||||
const connected = await player.connect();
|
||||
if (!connected) {
|
||||
throw new Error("Unable to connect to Spotify playback.");
|
||||
}
|
||||
|
||||
playerRef.current = player;
|
||||
const deviceId = await readyPromise;
|
||||
deviceIdRef.current = deviceId;
|
||||
return player;
|
||||
})()
|
||||
.catch((error_: unknown) => {
|
||||
playerRef.current?.disconnect();
|
||||
playerRef.current = null;
|
||||
deviceIdRef.current = null;
|
||||
throw error_;
|
||||
})
|
||||
.finally(() => {
|
||||
playerPromiseRef.current = null;
|
||||
});
|
||||
|
||||
return playerPromiseRef.current;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trackUri || playerRef.current || playerPromiseRef.current) return;
|
||||
void ensurePlayer({ silent: true }).catch(() => {
|
||||
// Ignore preload failures; explicit enable will surface them.
|
||||
});
|
||||
}, [trackUri, ensurePlayer]);
|
||||
|
||||
const pause = useCallback(async () => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
|
@ -87,83 +212,17 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
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 player = playerRef.current ?? (await ensurePlayer());
|
||||
const deviceId = deviceIdRef.current;
|
||||
if (!player || !deviceId) {
|
||||
throw new Error("Spotify playback device is not ready.");
|
||||
}
|
||||
|
||||
await player.activateElement();
|
||||
|
||||
const accessToken = await fetchSpotifyAccessToken();
|
||||
const response = await fetch(
|
||||
`https://api.spotify.com/v1/me/player/play?device_id=${encodeURIComponent(deviceId)}`,
|
||||
|
|
@ -201,7 +260,9 @@ export function useSpotifyPlayer(trackUri: string | null | undefined) {
|
|||
}
|
||||
|
||||
try {
|
||||
await ensurePlayer();
|
||||
if (!playerRef.current) {
|
||||
await ensurePlayer();
|
||||
}
|
||||
if (trackUriRef.current) {
|
||||
await playTrack(trackUriRef.current);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Link } from "@tanstack/react-router";
|
||||
import { authClient } from "#/lib/auth-client";
|
||||
|
||||
export default function BetterAuthHeader() {
|
||||
|
|
@ -22,24 +21,22 @@ export default function BetterAuthHeader() {
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
void authClient.signOut();
|
||||
}}
|
||||
className="flex-1 h-9 px-4 text-sm font-medium bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-50 border border-neutral-300 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
|
||||
<a
|
||||
href="/logout"
|
||||
className="flex-1 inline-flex h-9 items-center justify-center px-4 text-sm font-medium bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-50 border border-neutral-300 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/demo/better-auth"
|
||||
<a
|
||||
href="/demo/better-auth"
|
||||
className="h-9 px-4 text-sm font-medium bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-50 border border-neutral-300 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors inline-flex items-center"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,15 @@
|
|||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as LogoutRouteImport } from './routes/logout'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const LogoutRoute = LogoutRouteImport.update({
|
||||
id: '/logout',
|
||||
path: '/logout',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
|
|
@ -26,31 +32,42 @@ const IndexRoute = IndexRouteImport.update({
|
|||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/logout': typeof LogoutRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/logout': typeof LogoutRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/logout': typeof LogoutRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/login'
|
||||
fullPaths: '/' | '/login' | '/logout'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/login'
|
||||
id: '__root__' | '/' | '/login'
|
||||
to: '/' | '/login' | '/logout'
|
||||
id: '__root__' | '/' | '/login' | '/logout'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
LogoutRoute: typeof LogoutRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/logout': {
|
||||
id: '/logout'
|
||||
path: '/logout'
|
||||
fullPath: '/logout'
|
||||
preLoaderRoute: typeof LogoutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
|
|
@ -71,6 +88,7 @@ declare module '@tanstack/react-router' {
|
|||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
LogoutRoute: LogoutRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
|
|||
}),
|
||||
shellComponent: RootDocument,
|
||||
beforeLoad: async ({ context, location }) => {
|
||||
const authPublicPaths = new Set(["/login"]);
|
||||
const authPublicPaths = new Set(["/login", "/logout"]);
|
||||
const isAuthPublicPath = authPublicPaths.has(location.pathname);
|
||||
let session: AuthSession | null;
|
||||
if (typeof window === "undefined") {
|
||||
|
|
|
|||
24
web/src/routes/logout.tsx
Normal file
24
web/src/routes/logout.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { signOutAndClearQueryCache } from "#/lib/auth-client";
|
||||
|
||||
export const Route = createFileRoute("/logout")({
|
||||
component: LogoutRoute,
|
||||
});
|
||||
|
||||
function LogoutRoute() {
|
||||
const { queryClient } = Route.useRouteContext();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
void signOutAndClearQueryCache({
|
||||
queryClient,
|
||||
navigateToLogin: () => router.navigate({ to: "/login", replace: true }),
|
||||
});
|
||||
}, [queryClient, router]);
|
||||
|
||||
return (
|
||||
<div className="p-6 text-sm text-muted-foreground">Signing out...</div>
|
||||
);
|
||||
}
|
||||
5
web/src/types/spotify.d.ts
vendored
5
web/src/types/spotify.d.ts
vendored
|
|
@ -39,6 +39,8 @@ declare global {
|
|||
| "playback_error",
|
||||
callback: (payload: { message: string }) => void,
|
||||
): void;
|
||||
addListener(event: "autoplay_failed", callback: () => void): void;
|
||||
activateElement(): Promise<void>;
|
||||
}
|
||||
|
||||
type SpotifyPlayerEvent =
|
||||
|
|
@ -47,5 +49,6 @@ declare global {
|
|||
| "initialization_error"
|
||||
| "authentication_error"
|
||||
| "account_error"
|
||||
| "playback_error";
|
||||
| "playback_error"
|
||||
| "autoplay_failed";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue