Last FM
Show them what you're listening to.
Preview
Installation
src/player.tsx
1"use client";
2
3import { motion } from "motion/react";
4import { useEffect, useRef, useState } from "react";
5import Image from "next/image";
6import { defaultSong } from "@/constants/song";
7
8type Song = {
9 src: string;
10 name: string;
11 artist: string;
12 album: string;
13};
14
15const DEFAULT_LQIP =
16 "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiBmaWxsPSIjMzMzIi8+PC9zdmc+";
17
18const POLL_INTERVAL = 60_000;
19
20export const Player = () => {
21 const [song, setSong] = useState<Song | null>(null);
22 const [loading, setLoading] = useState(true);
23 const [error, setError] = useState(false);
24
25 const controllerRef = useRef<AbortController | null>(null);
26 const isFetchingRef = useRef(false);
27 const lastSongKeyRef = useRef<string>("");
28
29 const fetchNowPlaying = async () => {
30 if (isFetchingRef.current) return;
31 isFetchingRef.current = true;
32
33 const controller = new AbortController();
34 controllerRef.current = controller;
35
36 try {
37 setError(false);
38
39 const res = await fetch("/api/track", {
40 signal: controller.signal,
41 cache: "no-store",
42 });
43
44 if (!res.ok) throw new Error("Failed to fetch track");
45
46 const result = await res.json();
47 const nextSong: Song | null = result.song ?? null;
48
49 const nextKey = nextSong
50 ? `${nextSong.name}|${nextSong.artist}|${nextSong.album}|${nextSong.src}`
51 : "";
52
53 if (nextKey !== lastSongKeyRef.current) {
54 lastSongKeyRef.current = nextKey;
55 setSong(nextSong);
56 }
57 } catch (err: any) {
58 if (err?.name !== "AbortError") {
59 console.error("Now playing fetch failed:", err);
60 setError(true);
61 }
62 } finally {
63 setLoading(false);
64 isFetchingRef.current = false;
65 }
66 };
67
68 useEffect(() => {
69 setLoading(true);
70 fetchNowPlaying();
71
72 const interval = setInterval(() => {
73 fetchNowPlaying();
74 }, POLL_INTERVAL);
75
76 const onFocus = () => {
77 fetchNowPlaying();
78 };
79
80 window.addEventListener("focus", onFocus);
81
82 return () => {
83 clearInterval(interval);
84 window.removeEventListener("focus", onFocus);
85 controllerRef.current?.abort();
86 };
87 }, []);
88
89 if (loading) return <PlayerSkeleton />;
90
91 if (error) return null;
92
93 if (!song) return <PlayerCard song={defaultSong} />;
94
95 return <PlayerCard song={song!} />;
96};
97
98const PlayerCard = ({ song }: { song: Song }) => {
99 return (
100 <motion.div
101 className="flex w-fit min-w-52 items-center rounded-md"
102 style={{
103 background: "linear-gradient(90deg, #7b61ff, #00ccb1, #ffc414)",
104 backgroundSize: "200% 200%",
105 }}
106 animate={{
107 backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
108 opacity: 1,
109 filter: "blur(0px)",
110 }}
111 initial={{ opacity: 0, filter: "blur(12px)" }}
112 transition={{
113 backgroundPosition: {
114 duration: 6,
115 repeat: Infinity,
116 repeatType: "reverse",
117 },
118 opacity: { duration: 0.7 },
119 filter: { duration: 0.7 },
120 }}
121 aria-label="Now playing"
122 >
123 <div className="flex min-w-52 items-center rounded-md bg-black/35 p-1 backdrop-blur-xl">
124 <Image
125 src={song.src}
126 alt={`${song.name} album cover`}
127 width={64}
128 height={64}
129 className="size-16 rounded-md object-cover"
130 placeholder="blur"
131 blurDataURL={DEFAULT_LQIP}
132 sizes="64px"
133 />
134
135 <div className="flex flex-col gap-0.5 pl-2">
136 <span className="text-sm leading-tight font-semibold text-white">
137 {song.name}
138 </span>
139 <span className="text-xs font-medium text-white">{song.artist}</span>
140 <span className="text-[11px] text-white italic">{song.album}</span>
141 </div>
142 </div>
143 </motion.div>
144 );
145};
146
147const PlayerSkeleton = () => (
148 <motion.div
149 className="flex w-fit min-w-52 items-center rounded-md"
150 style={{
151 background: "linear-gradient(90deg, #7b61ff, #00ccb1, #ffc414)",
152 backgroundSize: "200% 200%",
153 }}
154 animate={{
155 backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
156 opacity: 1,
157 filter: "blur(0px)",
158 }}
159 initial={{ opacity: 0, filter: "blur(12px)" }}
160 transition={{
161 backgroundPosition: {
162 duration: 6,
163 repeat: Infinity,
164 repeatType: "reverse",
165 },
166 opacity: { duration: 0.4 },
167 filter: { duration: 0.4 },
168 }}
169 aria-hidden
170 >
171 <div className="flex min-w-52 animate-pulse items-center rounded-md bg-black/35 p-1 backdrop-blur-xl">
172 <div className="size-16 rounded-md bg-white/15" />
173 <div className="flex flex-col gap-1 pl-2">
174 <div className="h-4 w-28 rounded-md bg-white/15" />
175 <div className="h-4 w-16 rounded-md bg-white/15" />
176 <div className="h-4 w-20 rounded-md bg-white/15" />
177 </div>
178 </div>
179 </motion.div>
180);
181constants/song.ts
1type Song = {
2 src: string;
3 name: string;
4 artist: string;
5 album: string;
6};
7
8export const defaultSong: Song = {
9 src: "/airport.png",
10 name: "Airport Security",
11 artist: "Juice WRLD",
12 album: "9 9 9",
13};
14
15export const defaultImgSrc =
16 "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png";
17
18export const gradientImgSrc = "/gradient.png";
19
20export const lastFmUrl = process.env.LASTFM_URL!;
21