forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
1import styled from "@emotion/styled";
2import { Link as DefaultLink } from "@tanstack/react-router";
3import axios from "axios";
4import { ProgressBar } from "baseui/progress-bar";
5import { LabelXSmall } from "baseui/typography";
6import { useAtom, useAtomValue } from "jotai";
7import _ from "lodash";
8import { useCallback, useEffect, useRef } from "react";
9import { playerAtom } from "../../../atoms/player";
10import { userNowPlayingAtom } from "../../../atoms/userNowplaying";
11import { API_URL } from "../../../consts";
12import { useTimeFormat } from "../../../hooks/useFormat";
13import styles from "./styles";
14
15const Cover = styled.img`
16 width: 54px;
17 height: 54px;
18 margin-right: 16px;
19 border-radius: 5px;
20`;
21
22const Link = styled(DefaultLink)`
23 text-decoration: none;
24 &:hover {
25 text-decoration: underline;
26 }
27`;
28
29type NowPlayingProps = {
30 did: string;
31};
32
33function NowPlaying({ did }: NowPlayingProps) {
34 const { formatTime } = useTimeFormat();
35 const progressInterval = useRef<number | null>(null);
36 const lastFetchedRef = useRef(0);
37 const nowPlayingInterval = useRef<number | null>(null);
38 const [nowPlaying, setNowPlaying] = useAtom(userNowPlayingAtom);
39 const player = useAtomValue(playerAtom);
40
41 const fetchCurrentlyPlaying = useCallback(async () => {
42 if (player === "rockbox" || player === null) {
43 const [rockbox, spotify] = await Promise.all([
44 axios.get(`${API_URL}/now-playing`, {
45 headers: {
46 authorization: `Bearer ${localStorage.getItem("token")}`,
47 },
48 params: {
49 did,
50 },
51 }),
52 axios.get(`${API_URL}/spotify/currently-playing`, {
53 headers: {
54 authorization: `Bearer ${localStorage.getItem("token")}`,
55 },
56 params: {
57 did,
58 },
59 }),
60 ]);
61
62 if (rockbox.data.title) {
63 setNowPlaying({
64 ...nowPlaying,
65 [did]: {
66 title: rockbox.data.title,
67 artist: rockbox.data.album_artist || rockbox.data.artist,
68 artistUri: rockbox.data.artist_uri,
69 songUri: rockbox.data.song_uri,
70 albumUri: rockbox.data.album_uri,
71 duration: rockbox.data.length,
72 progress: rockbox.data.elapsed,
73 albumArt: _.get(rockbox.data, "album_art"),
74 isPlaying: rockbox.data.is_playing,
75 },
76 });
77 } else {
78 if (!spotify.data.item) {
79 setNowPlaying({
80 ...nowPlaying,
81 [did]: null,
82 });
83 }
84 }
85
86 if (rockbox.data.title) {
87 return;
88 }
89 }
90 const { data } = await axios.get(`${API_URL}/spotify/currently-playing`, {
91 headers: {
92 authorization: `Bearer ${localStorage.getItem("token")}`,
93 },
94 params: {
95 did,
96 },
97 });
98 if (data.item) {
99 setNowPlaying({
100 ...nowPlaying,
101 [did]: {
102 title: data.item.name,
103 artist: data.item.artists[0].name,
104 artistUri: data.artistUri,
105 songUri: data.songUri,
106 albumUri: data.albumUri,
107 duration: data.item.duration_ms,
108 progress: data.progress_ms,
109 albumArt: _.get(data, "item.album.images.0.url"),
110 isPlaying: data.is_playing,
111 },
112 });
113 } else {
114 setNowPlaying({
115 ...nowPlaying,
116 [did]: null,
117 });
118 }
119 lastFetchedRef.current = Date.now();
120 // eslint-disable-next-line react-hooks/exhaustive-deps
121 }, [setNowPlaying, did, player]);
122
123 const startProgressTracking = useCallback(() => {
124 if (progressInterval.current) {
125 clearInterval(progressInterval.current);
126 }
127
128 progressInterval.current = window.setInterval(() => {
129 setNowPlaying((prev) => {
130 if (!prev[did] || !prev[did].duration) {
131 return prev;
132 }
133
134 if (prev[did].progress >= prev[did].duration) {
135 setTimeout(fetchCurrentlyPlaying, 2000);
136 return prev;
137 }
138
139 if (prev[did].isPlaying) {
140 const progress = prev[did].progress + 100;
141 return {
142 ...prev,
143 [did]: {
144 ...prev[did],
145 progress,
146 },
147 };
148 }
149
150 return prev;
151 });
152 }, 100);
153 }, [fetchCurrentlyPlaying, setNowPlaying, did]);
154
155 useEffect(() => {
156 startProgressTracking();
157
158 return () => {
159 if (progressInterval.current) {
160 clearInterval(progressInterval.current);
161 }
162 };
163 // eslint-disable-next-line react-hooks/exhaustive-deps
164 }, []);
165
166 useEffect(() => {
167 if (nowPlayingInterval.current) {
168 clearInterval(nowPlayingInterval.current);
169 }
170 nowPlayingInterval.current = window.setInterval(() => {
171 fetchCurrentlyPlaying();
172 }, 15000);
173
174 fetchCurrentlyPlaying();
175
176 return () => {
177 if (nowPlayingInterval.current) {
178 clearInterval(nowPlayingInterval.current);
179 }
180 };
181 // eslint-disable-next-line react-hooks/exhaustive-deps
182 }, []);
183
184 return (
185 <>
186 {!!nowPlaying[did]?.duration && (
187 <>
188 <div className="flex flex-row items-center mt-[25px]">
189 {!!nowPlaying[did]?.albumUri && (
190 <Link
191 to={`/${nowPlaying[did]?.albumUri?.split("at://")[1].replace("app.rocksky.", "")}`}
192 >
193 <Cover src={nowPlaying[did]?.albumArt} />
194 </Link>
195 )}
196 {!nowPlaying[did]?.albumUri && (
197 <Cover src={nowPlaying[did]?.albumArt} />
198 )}
199 <div className="max-w-[316px] overflow-hidden">
200 <div className="max-w-[316px] overflow-hidden truncate">
201 {nowPlaying[did]?.songUri && (
202 <Link
203 to={`/${nowPlaying[did]?.songUri?.split("at://")[1].replace("app.rocksky.", "")}`}
204 className="font-semibold truncate whitespace-nowrap text-[var(--color-text)]"
205 >
206 {nowPlaying[did]?.title}
207 </Link>
208 )}
209 {!nowPlaying[did]?.songUri && (
210 <div className="font-semibold truncate whitespace-nowrap text-[var(--color-text)]">
211 {nowPlaying[did]?.title}
212 </div>
213 )}
214 </div>
215 <div className="max-w-[316px] overflow-hidden truncate">
216 {!!nowPlaying[did]?.artistUri?.split("at://")[1] && (
217 <Link
218 to={`/${nowPlaying[did]?.artistUri?.split("at://")[1].replace("app.rocksky.", "")}`}
219 className="text-[var(--color-text-muted)] font-semibold truncate whitespace-nowrap text-sm"
220 style={{ color: "var(--color-text-muted)" }}
221 >
222 {nowPlaying[did]?.artist}
223 </Link>
224 )}
225 {!nowPlaying[did]?.artistUri?.split("at://")[1] && (
226 <div
227 className="text-[var(--color-text-muted)] font-semibold truncate whitespace-nowrap text-sm"
228 style={{ color: "var(--color-text-muted)" }}
229 >
230 {nowPlaying[did]?.artist}
231 </div>
232 )}
233 </div>
234 </div>
235 </div>
236 <div className="mt-[0px] flex flex-row items-center">
237 <div>
238 <LabelXSmall className="!text-[var(--color-text-muted)]">
239 {formatTime(nowPlaying[did]?.progress || 0)}
240 </LabelXSmall>
241 </div>
242 <div className="flex-1 ml-[10px] mr-[10px]">
243 <ProgressBar
244 value={
245 nowPlaying[did]?.progress && nowPlaying[did]?.duration
246 ? (nowPlaying[did].progress / nowPlaying[did].duration) *
247 100
248 : 0
249 }
250 overrides={styles.Progressbar}
251 />
252 </div>
253 <div>
254 <LabelXSmall className="!text-[var(--color-text-muted)]">
255 {formatTime(nowPlaying[did]?.duration || 0)}
256 </LabelXSmall>
257 </div>
258 </div>
259 </>
260 )}
261 </>
262 );
263}
264
265export default NowPlaying;