Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import * as Sentry from "@sentry/nextjs";
import { connection } from "next/server";
import Image from "next/image";
import * as stylex from "@stylexjs/stylex";
import type { NowPlayingTrack } from "@/app/utils/spotify";
import { getNowPlaying } from "@/app/utils/spotify";

type Props = {
track: NowPlayingTrack;
};
export async function SpotifyRecentlyPlayed() {
// Wait for Redis and Spotify at request time, inside the homepage Suspense
// boundary so the rest of the page can stream immediately.
await connection();

let track = null;
try {
track = await getNowPlaying();
} catch (error) {
Sentry.captureException(error);
}

if (!track) {
return <span hidden data-spotify-empty="" />;
}

export function RecentlyPlayed({ track }: Props) {
return (
<div {...stylex.props(styles.container)}>
<div>
<h2 {...stylex.props(styles.heading)}>Recently Played</h2>
<a
href={track.url}
Expand All @@ -26,7 +39,7 @@ export function RecentlyPlayed({ track }: Props) {
{...stylex.props(styles.album)}
/>
) : null}
<span>
<span {...stylex.props(styles.trackText)}>
<span {...stylex.props(styles.track)}>{track.track}</span>
{" — "}
<span {...stylex.props(styles.artists)}>{track.artists}</span>
Expand All @@ -43,28 +56,38 @@ const styles = stylex.create({
borderWidth: 1,
aspectRatio: "1 / 1",
display: "block",
flexShrink: 0,
height: 48,
width: 48,
},
artists: {
color: "var(--recently-played-artist)",
},
container: {
marginTop: "1.25rem",
},
heading: {
fontSize: "1rem",
fontWeight: 600,
lineHeight: "1.5rem",
},
link: {
gap: "0.75rem",
alignItems: "center",
display: "flex",
textDecorationLine: "none",
height: "3rem",
marginLeft: "1rem",
marginTop: "1.25rem",
maxWidth: "calc(100% - 1rem)",
width: "fit-content",
},
trackText: {
overflow: "hidden",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
display: "-webkit-box",
lineHeight: "1.5rem",
overflowWrap: "anywhere",
minWidth: 0,
},
track: {
color: "var(--recently-played-track)",
fontWeight: 500,
Expand Down
5 changes: 5 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,8 @@ li[data-unstyled="true"]::before {
content: "";
display: none;
}

/* Keep the loading slot stable, but collapse it when the lookup has no track. */
[data-spotify-slot]:has([data-spotify-empty]) {
display: none;
}
19 changes: 12 additions & 7 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@ import * as stylex from "@stylexjs/stylex";

import Link from "next/link";
import { Post } from "@/app/(writing)/utils/types";
import { RecentlyPlayed } from "@/app/components/RecentlyPlayed";
import { getNowPlaying } from "@/app/utils/spotify";
import { SpotifyRecentlyPlayed } from "@/app/components/SpotifyRecentlyPlayed";
import { Suspense } from "react";
import postsJson from "@/app/(writing)/posts.json";
import { sharedStyles } from "@/app/styles";

export const dynamic = "force-dynamic";

export default async function Page() {
export default function Page() {
const { posts } = postsJson;
const lastThreePosts: Post[] = posts.slice(0, 3);
const nowPlaying = await getNowPlaying();

return (
<section {...stylex.props(sharedStyles.pageSection)}>
Expand Down Expand Up @@ -105,7 +102,11 @@ export default async function Page() {
</a>
</li>
</ul>
{nowPlaying ? <RecentlyPlayed track={nowPlaying} /> : null}
<div data-spotify-slot="" {...stylex.props(styles.spotifySlot)}>
<Suspense fallback={null}>
<SpotifyRecentlyPlayed />
</Suspense>
</div>
<p {...stylex.props(styles.mt5)}>
You can read my{" "}
<Link aria-label="Link to Nathan's writing" href="/writing">
Expand Down Expand Up @@ -150,6 +151,10 @@ export default async function Page() {
}

const styles = stylex.create({
spotifySlot: {
height: "5.75rem",
marginTop: "1.25rem",
},
mt4: {
marginTop: "1rem",
},
Expand Down
34 changes: 20 additions & 14 deletions app/utils/spotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type NowPlayingTrack = {

const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
const SPOTIFY_API_BASE = "https://api.spotify.com/v1";
const SPOTIFY_REQUEST_TIMEOUT_MS = 3000;

export async function getAccessToken(): Promise<string | null> {
const clientId = process.env.SPOTIFY_CLIENT_ID;
Expand All @@ -29,13 +30,14 @@ export async function getAccessToken(): Promise<string | null> {

const res = await fetch(SPOTIFY_TOKEN_URL, {
cache: "no-store",
signal: AbortSignal.timeout(SPOTIFY_REQUEST_TIMEOUT_MS),
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});

if (!res.ok) {
return null;
throw new Error(`Spotify request failed (${res.status})`);
}

const data = (await res.json()) as { access_token?: string };
Expand All @@ -62,13 +64,18 @@ function normalizeTrack(item: {
export async function getCurrentlyPlaying(accessToken: string): Promise<NowPlayingTrack | null> {
const res = await fetch(`${SPOTIFY_API_BASE}/me/player/currently-playing`, {
cache: "no-store",
signal: AbortSignal.timeout(SPOTIFY_REQUEST_TIMEOUT_MS),
headers: { Authorization: `Bearer ${accessToken}` },
});

if (res.status === 204 || !res.ok) {
if (res.status === 204) {
return null;
}

if (!res.ok) {
throw new Error(`Spotify request failed (${res.status})`);
Comment on lines +75 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the recently-played fallback on scope errors

When the refresh token grants user-read-recently-played but not the distinct user-read-currently-playing scope, this optional current-track request can return 403 even though the recently-played request remains authorized; Spotify documents the endpoints with separate required scopes (current track, recent tracks). Throwing here prevents getNowPlaying() from reaching its recently-played fallback, whereas the previous implementation treated a non-OK current-track response as a miss, so preserve that fallback for this case.

Useful? React with 👍 / 👎.

}

const data = (await res.json()) as {
item?: {
name: string;
Expand All @@ -88,11 +95,12 @@ export async function getCurrentlyPlaying(accessToken: string): Promise<NowPlayi
export async function getRecentlyPlayed(accessToken: string, limit = 1): Promise<NowPlayingTrack | null> {
const res = await fetch(`${SPOTIFY_API_BASE}/me/player/recently-played?limit=${limit}`, {
cache: "no-store",
signal: AbortSignal.timeout(SPOTIFY_REQUEST_TIMEOUT_MS),
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
return null;
throw new Error(`Spotify request failed (${res.status})`);
}

const data = (await res.json()) as {
Expand All @@ -115,23 +123,21 @@ export async function getRecentlyPlayed(accessToken: string, limit = 1): Promise
}

export async function getNowPlaying(): Promise<NowPlayingTrack | null> {
const accessToken = await getAccessToken();
if (!accessToken) {
return null;
}

if (process.env.NODE_ENV === "production") {
const recentlyPlayedCache = await redis.get<NowPlayingTrack | null>(getSpotifyRecentlyPlayedRedisKey());
if (recentlyPlayedCache) {
return recentlyPlayedCache;
const cachedTrack = await redis.get<NowPlayingTrack | null>(getSpotifyRecentlyPlayedRedisKey());
if (cachedTrack) {
return cachedTrack;
Comment on lines +127 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check optional Spotify credentials before serving the cache

In production, this cache lookup now occurs before confirming that the optional Spotify credentials are configured. If a deployment removes or incompletely configures those variables, an old listening record can continue to be published until the Redis TTL expires; with an empty cache, the disabled widget also performs a needless Redis operation. The previous ordering returned null before touching the cache, so add a local credential-presence check before this lookup without necessarily refreshing the access token.

AGENTS.md reference: AGENTS.md:L95-L97

Useful? React with 👍 / 👎.

}
}

let track: NowPlayingTrack | null = await getCurrentlyPlaying(accessToken);
if (!track) {
track = await getRecentlyPlayed(accessToken, 1);
const accessToken = await getAccessToken();
if (!accessToken) {
return null;
}

const currentTrack = await getCurrentlyPlaying(accessToken);
const track = currentTrack ?? (await getRecentlyPlayed(accessToken, 1));

if (process.env.NODE_ENV === "production") {
await redis.set(getSpotifyRecentlyPlayedRedisKey(), track, SPOTIFY_RECENTLY_PLAYED_TTL_S);
}
Expand Down
Loading