Skip to content
Closed
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,27 +1,14 @@
import * as Sentry from "@sentry/nextjs";
import { connection } from "next/server";
import Image from "next/image";
import * as stylex from "@stylexjs/stylex";
import { getNowPlaying } from "@/app/utils/spotify";
import type { NowPlayingTrack } from "@/app/utils/spotify";

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="" />;
}
type Props = {
track: NowPlayingTrack;
};

export function RecentlyPlayed({ track }: Props) {
return (
<div>
<div {...stylex.props(styles.container)}>
<h2 {...stylex.props(styles.heading)}>Recently Played</h2>
<a
href={track.url}
Expand All @@ -39,7 +26,7 @@ export async function SpotifyRecentlyPlayed() {
{...stylex.props(styles.album)}
/>
) : null}
<span {...stylex.props(styles.trackText)}>
<span>
<span {...stylex.props(styles.track)}>{track.track}</span>
{" — "}
<span {...stylex.props(styles.artists)}>{track.artists}</span>
Expand All @@ -56,38 +43,28 @@ 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",
Comment on lines 64 to 66

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 Constrain long track text to the viewport

On narrow viewports or for Spotify metadata containing a long unbroken track or artist name, this fit-content flex link has no maximum width and its text child has neither min-width: 0 nor forced wrapping. The row can therefore grow wider than the homepage section and introduce horizontal scrolling; constrain the link to the available width and restore wrapping or clamping on the text child.

Useful? React with 👍 / 👎.

},
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: 0 additions & 5 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,3 @@ 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: 7 additions & 12 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import * as stylex from "@stylexjs/stylex";

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

export default function Page() {
export const dynamic = "force-dynamic";

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep Spotify off the homepage critical path

When Redis or Spotify is slow or unreachable, this await runs before any homepage JSX is returned and outside local error handling. The underlying Redis operations and fetch calls can reject, and the Spotify requests no longer have abort timeouts, so the otherwise static / route can either fail completely or remain blocked until the deployment timeout; keep this optional widget behind a guarded streaming boundary or otherwise catch and bound the lookup.

Useful? React with 👍 / 👎.


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

const styles = stylex.create({
spotifySlot: {
height: "5.75rem",
marginTop: "1.25rem",
},
mt4: {
marginTop: "1rem",
},
Expand Down
34 changes: 14 additions & 20 deletions app/utils/spotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ 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 @@ -30,14 +29,13 @@ 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) {
throw new Error(`Spotify request failed (${res.status})`);
return null;
}

const data = (await res.json()) as { access_token?: string };
Expand All @@ -64,18 +62,13 @@ 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) {
if (res.status === 204 || !res.ok) {
return null;
Comment on lines +68 to 69

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 Distinguish Spotify failures from an idle player

When the currently-playing endpoint responds with an error such as 401, 429, or 5xx, this condition treats it exactly like the valid 204 “nothing playing” response. getNowPlaying() then makes another request to the recently-played endpoint and can cache an older track for the full one-hour TTL even though the current state was simply unavailable; reserve null for the valid empty response and propagate or explicitly handle error statuses.

Useful? React with 👍 / 👎.

}

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

const data = (await res.json()) as {
item?: {
name: string;
Expand All @@ -95,12 +88,11 @@ 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) {
throw new Error(`Spotify request failed (${res.status})`);
return null;
}

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

export async function getNowPlaying(): Promise<NowPlayingTrack | null> {
if (process.env.NODE_ENV === "production") {
const cachedTrack = await redis.get<NowPlayingTrack | null>(getSpotifyRecentlyPlayedRedisKey());
if (cachedTrack) {
return cachedTrack;
}
}

const accessToken = await getAccessToken();

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 the production cache before refreshing the token

On every production homepage request, this refreshes the Spotify access token before consulting the one-hour Redis cache. Consequently, even a cache hit incurs an external request, and a token-endpoint error response returns null before the valid cached track can be read; moving the cache lookup ahead of getAccessToken() preserves the cache's latency and availability benefits.

Useful? React with 👍 / 👎.

if (!accessToken) {
return null;
}

const currentTrack = await getCurrentlyPlaying(accessToken);
const track = currentTrack ?? (await getRecentlyPlayed(accessToken, 1));
if (process.env.NODE_ENV === "production") {
const recentlyPlayedCache = await redis.get<NowPlayingTrack | null>(getSpotifyRecentlyPlayedRedisKey());
if (recentlyPlayedCache) {
return recentlyPlayedCache;
}
}

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

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