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);
181
constants/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

Built with by Harshit Gulati