Skip to content

Commit 86ade86

Browse files
feat: change music to be configurable
1 parent 042418a commit 86ade86

14 files changed

Lines changed: 402 additions & 114 deletions

File tree

apps/web/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Set these in `apps/web/.env` for local development:
3737

3838
- `GITHUB_TOKEN`
3939
- `LAST_FM_TOKEN`
40+
- `MUSIC_WIDGET_PROVIDER` (`apple-music` or `lastfm`; defaults to `apple-music`)
4041
- `APPLE_MUSIC_USER_TOKEN`
4142
- `VITE_EMAIL_TURNSTILE_SITE_KEY`
4243

apps/web/app/routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export default [
2626
route("anyone-can-draw", "routes/redirect/anyone-can-draw.tsx"),
2727
// Resource routes
2828
route("api/spotify", "routes/api/spotify.tsx"),
29-
route("api/apple-music", "routes/api/apple-music.tsx"),
29+
route("api/music", "routes/api/music.tsx"),
3030
route("api/github", "routes/api/github.tsx"),
3131
route("api/projects", "routes/api/projects.tsx"),
3232
route("api/gists", "routes/api/gists.tsx"),

apps/web/app/routes/_index.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { MetaFunction, LoaderFunctionArgs } from "react-router";
33
import { Await, useLoaderData } from "react-router";
44
import { ChevronUp } from "lucide-react";
55

6-
import { getRecentlyPlayed } from "@/lib/apple-music/getRecentlyPlayed";
6+
import { getRecentlyPlayedMusic } from "@/lib/music/getRecentlyPlayed";
77
import { getProjects } from "@/lib/data/projects";
88
import { getGitHubRepos } from "@/lib/data/github";
99
import { PageLayout } from "@/components/PageLayout";
@@ -28,15 +28,15 @@ export async function loader({ request, context }: LoaderFunctionArgs) {
2828
executionContext: context?.cloudflare?.ctx,
2929
};
3030

31-
const applemusic = getRecentlyPlayed(10, cacheContext).catch(() => null);
31+
const music = getRecentlyPlayedMusic(10, cacheContext).catch(() => null);
3232
const featuredRepos = getGitHubRepos({ limit: 8, cacheContext })
3333
.then((repos) => repos ?? null)
3434
.catch(() => null);
3535
const blogPosts = getPaginatedBlogPosts({ limit: 6 }, cacheContext).catch(() => []);
3636
const projects = await getProjects();
3737

3838
return {
39-
applemusic,
39+
music,
4040
projects,
4141
featuredRepos,
4242
blogPosts,
@@ -81,11 +81,11 @@ export default function Home() {
8181
<Suspense
8282
fallback={<LoadingState label="Loading recent tracks..." className="w-full py-6" />}
8383
>
84-
<Await resolve={data.applemusic}>
85-
{(applemusic) => (
84+
<Await resolve={data.music}>
85+
{(music) => (
8686
<>
87-
<AppleMusicWidget data={applemusic ?? undefined} />
88-
{applemusic && (
87+
<AppleMusicWidget data={music ?? undefined} />
88+
{music && (
8989
<div className="text-sm text-muted-foreground text-center inline-flex justify-center w-full mt-5">
9090
<span>What I&apos;m listening to</span>
9191
<ChevronUp />

apps/web/app/routes/api/apple-music.tsx

Lines changed: 0 additions & 14 deletions
This file was deleted.

apps/web/app/routes/api/music.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { LoaderFunctionArgs } from "react-router";
2+
3+
import { getRecentlyPlayedMusic } from "@/lib/music/getRecentlyPlayed";
4+
import { CDN_CACHE_HEADERS } from "@/lib/constants";
5+
6+
export async function loader({ request, context }: LoaderFunctionArgs) {
7+
const cacheContext = {
8+
request,
9+
executionContext: context?.cloudflare?.ctx,
10+
};
11+
12+
const data = await getRecentlyPlayedMusic(10, cacheContext).catch(() => null);
13+
14+
if (!data) {
15+
return Response.json(
16+
{ error: "Failed to fetch recently played music" },
17+
{
18+
status: 500,
19+
headers: CDN_CACHE_HEADERS,
20+
},
21+
);
22+
}
23+
24+
return Response.json(data, {
25+
headers: CDN_CACHE_HEADERS,
26+
});
27+
}

apps/web/components/AppleMusicWidget/index.tsx

Lines changed: 110 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,55 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react";
55
import "./styles.css";
66

77
import { Image } from "@/components/Image";
8-
import { createWidgetStyles } from "@/lib/apple-music/artwork";
98
import imageLoader from "@/lib/imageLoader";
10-
import type { RecentTracks } from "@/types/apple-music";
9+
import type { MusicWidgetData, MusicWidgetTrack } from "@/types/music";
1110
import { PauseIcon } from "@/components/Icons/PauseIcon";
1211
import { PlayIcon } from "@/components/Icons/PlayIcon";
1312

14-
export function AppleMusicWidget({ data }: { data: RecentTracks | undefined }) {
15-
if (!data) {
16-
return null;
13+
function TrackAction({
14+
track,
15+
isPlaying,
16+
className,
17+
onToggle,
18+
}: {
19+
track: MusicWidgetTrack;
20+
isPlaying: boolean;
21+
className: string;
22+
onToggle: (track: MusicWidgetTrack) => void;
23+
}) {
24+
if (track.previewUrl) {
25+
return (
26+
<button
27+
type="button"
28+
aria-label={`${isPlaying ? "Pause" : "Play preview of"} ${track.name}`}
29+
className={className}
30+
onClick={() => onToggle(track)}
31+
data-playing={isPlaying ? "true" : "false"}
32+
>
33+
{isPlaying ? <PauseIcon /> : <PlayIcon />}
34+
</button>
35+
);
1736
}
1837

19-
const tracksList = data;
38+
if (!track.url) {
39+
return null;
40+
}
2041

21-
const firstTrack = tracksList?.length > 0 ? tracksList[0] : null;
22-
const firstTrackImage = firstTrack?.attributes.artwork.url
23-
? firstTrack.attributes.artwork.url.replace("{w}", "700").replace("{h}", "245")
24-
: null;
25-
const widgetStyle = createWidgetStyles(firstTrack?.attributes.artwork);
42+
return (
43+
<a
44+
aria-label={`Open ${track.name}`}
45+
className={className}
46+
rel="noopener noreferrer nofollow"
47+
target="_blank"
48+
href={track.url}
49+
data-playing="false"
50+
>
51+
<PlayIcon />
52+
</a>
53+
);
54+
}
2655

56+
export function AppleMusicWidget({ data }: { data: MusicWidgetData | undefined }) {
2757
const [currentTrackId, setCurrentTrackId] = useState<string | null>(null);
2858
const [isPlaying, setIsPlaying] = useState(false);
2959
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -72,13 +102,18 @@ export function AppleMusicWidget({ data }: { data: RecentTracks | undefined }) {
72102
};
73103
}, []);
74104

105+
const tracksList = data?.tracks ?? [];
106+
const firstTrack = tracksList.length > 0 ? tracksList[0] : null;
107+
const firstTrackImage = firstTrack?.artworkUrl ?? null;
108+
const widgetStyle = data?.style;
109+
75110
const handleToggleTrack = useCallback(
76-
async (track: RecentTracks[number]) => {
77-
const previewUrl = track?.attributes?.previews?.[0]?.url;
111+
async (track: MusicWidgetTrack) => {
112+
const previewUrl = track.previewUrl;
78113

79114
if (!previewUrl) {
80-
if (track?.attributes?.url) {
81-
window.open(track.attributes.url, "_blank", "noopener,noreferrer");
115+
if (track.url) {
116+
window.open(track.url, "_blank", "noopener,noreferrer");
82117
}
83118
return;
84119
}
@@ -123,6 +158,10 @@ export function AppleMusicWidget({ data }: { data: RecentTracks | undefined }) {
123158
[currentTrackId, isPlaying],
124159
);
125160

161+
if (!data || !firstTrack) {
162+
return null;
163+
}
164+
126165
return (
127166
<div id="applemusic-widget" style={widgetStyle}>
128167
<Suspense fallback={<div>Loading...</div>}>
@@ -137,7 +176,7 @@ export function AppleMusicWidget({ data }: { data: RecentTracks | undefined }) {
137176
}}
138177
>
139178
<Image
140-
alt={firstTrack.attributes.name}
179+
alt={firstTrack.name}
141180
src={imageLoader({
142181
src: firstTrackImage,
143182
width: 700,
@@ -156,84 +195,70 @@ export function AppleMusicWidget({ data }: { data: RecentTracks | undefined }) {
156195
<div className="applemusic-widget-latest-overlay">
157196
<div className="applemusic-widget-latest-actions">
158197
<div className="applemusic-widget-latest-meta">
159-
<h3>{firstTrack.attributes.name}</h3>
160-
<span>{firstTrack.attributes.artistName}</span>
161-
<span>{firstTrack.attributes.albumName}</span>
198+
<h3>{firstTrack.name}</h3>
199+
<span>{firstTrack.artistName}</span>
200+
<span>{firstTrack.albumName}</span>
162201
</div>
163-
<button
164-
type="button"
165-
aria-label={`${
166-
isTrackPlaying(firstTrack.id) ? "Pause" : "Play preview of"
167-
} ${firstTrack.attributes.name}`}
202+
<TrackAction
203+
track={firstTrack}
168204
className="trackLinkPlay"
169-
onClick={() => handleToggleTrack(firstTrack)}
170-
data-playing={isTrackPlaying(firstTrack.id) ? "true" : "false"}
171-
>
172-
{isTrackPlaying(firstTrack.id) ? <PauseIcon /> : <PlayIcon />}
173-
</button>
205+
isPlaying={isTrackPlaying(firstTrack.id)}
206+
onToggle={handleToggleTrack}
207+
/>
174208
</div>
175209
</div>
210+
{firstTrack.isNowPlaying ? (
211+
<div className="absolute left-0 bottom-0 z-10 bg-[#010517] text-primary-foreground px-1 py-1 text-sm">
212+
<span>Now Playing</span>
213+
</div>
214+
) : null}
176215
</div>
177216
<div className="applemusic-widget-tracks">
178-
{tracksList?.map((track, index) => {
179-
if (index !== 0) {
180-
const trackImage = track.attributes.artwork.url
181-
? track.attributes.artwork.url.replace("{w}", "700").replace("{h}", "245")
182-
: null;
183-
184-
const isPlayingTrack = isTrackPlaying(track.id);
185-
186-
return (
187-
<div
188-
className="applemusic-widget-track-item"
189-
key={`${track.id}_${track.attributes.issrc}`}
190-
>
191-
<div className="applemusic-widget-track-item-image">
192-
<div className="applemusic-widget-track-item-image-inner">
193-
{trackImage ? (
194-
<Image
195-
width="53"
196-
height="53"
197-
loading="lazy"
198-
alt={track.attributes.albumName}
199-
src={imageLoader({
200-
src: trackImage,
201-
width: 53,
202-
})}
203-
style={{
204-
objectFit: "cover",
205-
}}
206-
unoptimized
207-
/>
208-
) : (
209-
<div
210-
className="applemusic-widget-track-item-image-placeholder"
211-
aria-hidden="true"
212-
/>
213-
)}
214-
<button
215-
type="button"
216-
className="applemusic-widget-track-item-play"
217-
onClick={() => handleToggleTrack(track)}
218-
aria-label={`${
219-
isPlayingTrack ? "Pause" : "Play preview of"
220-
} ${track.attributes.name}`}
221-
data-playing={isPlayingTrack ? "true" : "false"}
222-
>
223-
{isPlayingTrack ? <PauseIcon /> : <PlayIcon />}
224-
</button>
225-
</div>
217+
{tracksList.slice(1).map((track) => {
218+
const isPlayingTrack = isTrackPlaying(track.id);
219+
220+
return (
221+
<div className="applemusic-widget-track-item" key={track.id}>
222+
<div className="applemusic-widget-track-item-image">
223+
<div className="applemusic-widget-track-item-image-inner">
224+
{track.artworkUrl ? (
225+
<Image
226+
width="53"
227+
height="53"
228+
loading="lazy"
229+
alt={track.albumName}
230+
src={imageLoader({
231+
src: track.artworkUrl,
232+
width: 53,
233+
})}
234+
style={{
235+
objectFit: "cover",
236+
}}
237+
unoptimized
238+
/>
239+
) : (
240+
<div
241+
className="applemusic-widget-track-item-image-placeholder"
242+
aria-hidden="true"
243+
/>
244+
)}
245+
<TrackAction
246+
track={track}
247+
className="applemusic-widget-track-item-play"
248+
isPlaying={isPlayingTrack}
249+
onToggle={handleToggleTrack}
250+
/>
226251
</div>
227-
<div className="applemusic-widget-track-item-content">
228-
<div className="applemusic-widget-track-item-text">
229-
<h3>{track.attributes.name}</h3>
230-
<span>{track.attributes.artistName}</span>
231-
<span>{track.attributes.albumName}</span>
232-
</div>
252+
</div>
253+
<div className="applemusic-widget-track-item-content">
254+
<div className="applemusic-widget-track-item-text">
255+
<h3>{track.name}</h3>
256+
<span>{track.artistName}</span>
257+
<span>{track.albumName}</span>
233258
</div>
234259
</div>
235-
);
236-
}
260+
</div>
261+
);
237262
})}
238263
</div>
239264
</>

apps/web/env.d.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
// by running `wrangler types --env-interface CloudflareEnv env.d.ts`
33

44
interface CloudflareEnv {
5-
LAST_FM_TOKEN: string;
5+
LAST_FM_TOKEN?: string;
6+
MUSIC_WIDGET_PROVIDER?: "apple-music" | "lastfm";
7+
APPLE_MUSIC_USER_TOKEN?: string;
8+
APPLE_MUSIC_PRIVATE_KEY?: string;
9+
APPLE_MUSIC_KEY_ID?: string;
10+
APPLE_MUSIC_TEAM_ID?: string;
611
}
712

813
interface ExecutionContext {

apps/web/lib/apple-music/artwork.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { CSSProperties } from "react";
1+
import type { CSSProperties } from "react";
22

33
import { ensureAccessibleTextColor, sanitizeHexColor, toRgbaString } from "../colors";
4-
import type { RecentTracks } from "../../types/apple-music";
4+
import type { MusicWidgetStyleArtwork } from "@/types/music";
55

6-
type Artwork = RecentTracks[number]["attributes"]["artwork"];
7-
8-
export function getArtworkColor(artwork: Artwork | null | undefined, ...keys): string | null {
6+
export function getArtworkColor(
7+
artwork: MusicWidgetStyleArtwork | null | undefined,
8+
...keys: (keyof MusicWidgetStyleArtwork)[]
9+
): string | null {
910
if (!artwork) {
1011
return null;
1112
}
@@ -22,7 +23,9 @@ export function getArtworkColor(artwork: Artwork | null | undefined, ...keys): s
2223
return null;
2324
}
2425

25-
export function createWidgetStyles(artwork?: Artwork | null): CSSProperties | undefined {
26+
export function createWidgetStyles(
27+
artwork?: MusicWidgetStyleArtwork | null,
28+
): CSSProperties | undefined {
2629
if (!artwork) {
2730
return undefined;
2831
}

0 commit comments

Comments
 (0)