A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useState, useEffect } from "react";
2import type { TealActorStatusRecord } from "../types/teal";
3
4export interface CurrentlyPlayingRendererProps {
5 record: TealActorStatusRecord;
6 error?: Error;
7 loading: boolean;
8 did: string;
9 rkey: string;
10 colorScheme?: "light" | "dark" | "system";
11 autoRefresh?: boolean;
12 /** Label to display (e.g., "CURRENTLY PLAYING", "LAST PLAYED"). Defaults to "CURRENTLY PLAYING". */
13 label?: string;
14 /** Refresh interval in milliseconds. Defaults to 15000 (15 seconds). */
15 refreshInterval?: number;
16 /** Handle to display in not listening state */
17 handle?: string;
18}
19
20interface SonglinkPlatform {
21 url: string;
22 entityUniqueId: string;
23 nativeAppUriMobile?: string;
24 nativeAppUriDesktop?: string;
25}
26
27interface SonglinkResponse {
28 linksByPlatform: {
29 [platform: string]: SonglinkPlatform;
30 };
31 entitiesByUniqueId: {
32 [id: string]: {
33 thumbnailUrl?: string;
34 title?: string;
35 artistName?: string;
36 };
37 };
38}
39
40export const CurrentlyPlayingRenderer: React.FC<CurrentlyPlayingRendererProps> = ({
41 record,
42 error,
43 loading,
44 autoRefresh = true,
45 label = "CURRENTLY PLAYING",
46 refreshInterval = 15000,
47 handle,
48}) => {
49 const [albumArt, setAlbumArt] = useState<string | undefined>(undefined);
50 const [artworkLoading, setArtworkLoading] = useState(true);
51 const [songlinkData, setSonglinkData] = useState<SonglinkResponse | undefined>(undefined);
52 const [showPlatformModal, setShowPlatformModal] = useState(false);
53 const [refreshKey, setRefreshKey] = useState(0);
54
55 // Auto-refresh interval
56 useEffect(() => {
57 if (!autoRefresh) return;
58
59 const interval = setInterval(() => {
60 // Reset loading state before refresh
61 setArtworkLoading(true);
62 setRefreshKey((prev) => prev + 1);
63 }, refreshInterval);
64
65 return () => clearInterval(interval);
66 }, [autoRefresh, refreshInterval]);
67
68 useEffect(() => {
69 if (!record) return;
70
71 const { item } = record;
72 const artistName = item.artists[0]?.artistName;
73 const trackName = item.trackName;
74
75 if (!artistName || !trackName) {
76 setArtworkLoading(false);
77 return;
78 }
79
80 // Reset loading state at start of fetch
81 if (refreshKey > 0) {
82 setArtworkLoading(true);
83 }
84
85 let cancelled = false;
86
87 const fetchMusicData = async () => {
88 try {
89 // Step 1: Check if we have an ISRC - Songlink supports this directly
90 if (item.isrc) {
91 console.log(`[teal.fm] Attempting ISRC lookup for ${trackName} by ${artistName}`, { isrc: item.isrc });
92 const response = await fetch(
93 `https://api.song.link/v1-alpha.1/links?platform=isrc&type=song&id=${encodeURIComponent(item.isrc)}&songIfSingle=true`
94 );
95 if (cancelled) return;
96 if (response.ok) {
97 const data = await response.json();
98 setSonglinkData(data);
99
100 // Extract album art from Songlink data
101 const entityId = data.entityUniqueId;
102 const entity = data.entitiesByUniqueId?.[entityId];
103 if (entity?.thumbnailUrl) {
104 console.log(`[teal.fm] ✓ Found album art via ISRC lookup`);
105 setAlbumArt(entity.thumbnailUrl);
106 } else {
107 console.warn(`[teal.fm] ISRC lookup succeeded but no thumbnail found`);
108 }
109 setArtworkLoading(false);
110 return;
111 } else {
112 console.warn(`[teal.fm] ISRC lookup failed with status ${response.status}`);
113 }
114 }
115
116 // Step 2: Search iTunes Search API to find the track (single request for both artwork and links)
117 console.log(`[teal.fm] Attempting iTunes search for: "${trackName}" by "${artistName}"`);
118 const iTunesSearchUrl = `https://itunes.apple.com/search?term=${encodeURIComponent(
119 `${trackName} ${artistName}`
120 )}&media=music&entity=song&limit=1`;
121
122 const iTunesResponse = await fetch(iTunesSearchUrl);
123
124 if (cancelled) return;
125
126 if (iTunesResponse.ok) {
127 const iTunesData = await iTunesResponse.json();
128
129 if (iTunesData.results && iTunesData.results.length > 0) {
130 const match = iTunesData.results[0];
131 const iTunesId = match.trackId;
132
133 // Set album artwork immediately (600x600 for high quality)
134 const artworkUrl = match.artworkUrl100?.replace('100x100', '600x600') || match.artworkUrl100;
135 if (artworkUrl) {
136 console.log(`[teal.fm] ✓ Found album art via iTunes search`, { url: artworkUrl });
137 setAlbumArt(artworkUrl);
138 } else {
139 console.warn(`[teal.fm] iTunes match found but no artwork URL`);
140 }
141 setArtworkLoading(false);
142
143 // Step 3: Use iTunes ID with Songlink to get all platform links
144 console.log(`[teal.fm] Fetching platform links via Songlink (iTunes ID: ${iTunesId})`);
145 const songlinkResponse = await fetch(
146 `https://api.song.link/v1-alpha.1/links?platform=itunes&type=song&id=${iTunesId}&songIfSingle=true`
147 );
148
149 if (cancelled) return;
150
151 if (songlinkResponse.ok) {
152 const songlinkData = await songlinkResponse.json();
153 console.log(`[teal.fm] ✓ Got platform links from Songlink`);
154 setSonglinkData(songlinkData);
155 return;
156 } else {
157 console.warn(`[teal.fm] Songlink request failed with status ${songlinkResponse.status}`);
158 }
159 } else {
160 console.warn(`[teal.fm] No iTunes results found for "${trackName}" by "${artistName}"`);
161 setArtworkLoading(false);
162 }
163 } else {
164 console.warn(`[teal.fm] iTunes search failed with status ${iTunesResponse.status}`);
165 }
166
167 // Step 4: Fallback - if originUrl is from a supported platform, try it directly
168 if (item.originUrl && (
169 item.originUrl.includes('spotify.com') ||
170 item.originUrl.includes('apple.com') ||
171 item.originUrl.includes('youtube.com') ||
172 item.originUrl.includes('tidal.com')
173 )) {
174 console.log(`[teal.fm] Attempting Songlink lookup via originUrl`, { url: item.originUrl });
175 const songlinkResponse = await fetch(
176 `https://api.song.link/v1-alpha.1/links?url=${encodeURIComponent(item.originUrl)}&songIfSingle=true`
177 );
178
179 if (cancelled) return;
180
181 if (songlinkResponse.ok) {
182 const data = await songlinkResponse.json();
183 console.log(`[teal.fm] ✓ Got data from Songlink via originUrl`);
184 setSonglinkData(data);
185
186 // Try to get artwork from Songlink if we don't have it yet
187 if (!albumArt) {
188 const entityId = data.entityUniqueId;
189 const entity = data.entitiesByUniqueId?.[entityId];
190 if (entity?.thumbnailUrl) {
191 console.log(`[teal.fm] ✓ Found album art via Songlink originUrl lookup`);
192 setAlbumArt(entity.thumbnailUrl);
193 } else {
194 console.warn(`[teal.fm] Songlink lookup succeeded but no thumbnail found`);
195 }
196 }
197 } else {
198 console.warn(`[teal.fm] Songlink originUrl lookup failed with status ${songlinkResponse.status}`);
199 }
200 }
201
202 if (!albumArt) {
203 console.warn(`[teal.fm] ✗ All album art fetch methods failed for "${trackName}" by "${artistName}"`);
204 }
205
206 setArtworkLoading(false);
207 } catch (err) {
208 console.error(`[teal.fm] ✗ Error fetching music data for "${trackName}" by "${artistName}":`, err);
209 setArtworkLoading(false);
210 }
211 };
212
213 fetchMusicData();
214
215 return () => {
216 cancelled = true;
217 };
218 }, [record, refreshKey]); // Add refreshKey to trigger refetch
219
220 if (error)
221 return (
222 <div style={{ padding: 8, color: "var(--atproto-color-error)" }}>
223 Failed to load status.
224 </div>
225 );
226 if (loading && !record)
227 return (
228 <div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}>
229 Loading…
230 </div>
231 );
232
233 const { item } = record;
234
235 // Check if user is not listening to anything
236 const isNotListening = !item.trackName || item.artists.length === 0;
237
238 // Show "not listening" state
239 if (isNotListening) {
240 const displayHandle = handle || "User";
241 return (
242 <div style={styles.notListeningContainer}>
243 <div style={styles.notListeningIcon}>
244 <svg
245 width="80"
246 height="80"
247 viewBox="0 0 24 24"
248 fill="none"
249 stroke="currentColor"
250 strokeWidth="1.5"
251 strokeLinecap="round"
252 strokeLinejoin="round"
253 >
254 <path d="M9 18V5l12-2v13" />
255 <circle cx="6" cy="18" r="3" />
256 <circle cx="18" cy="16" r="3" />
257 </svg>
258 </div>
259 <div style={styles.notListeningTitle}>
260 {displayHandle} isn't listening to anything
261 </div>
262 <div style={styles.notListeningSubtitle}>Check back soon</div>
263 </div>
264 );
265 }
266
267 const artistNames = item.artists.map((a) => a.artistName).join(", ");
268
269 const platformConfig: Record<string, { name: string; icon: string; color: string }> = {
270 spotify: { name: "Spotify", icon: "♫", color: "#1DB954" },
271 appleMusic: { name: "Apple Music", icon: "🎵", color: "#FA243C" },
272 youtube: { name: "YouTube", icon: "▶", color: "#FF0000" },
273 youtubeMusic: { name: "YouTube Music", icon: "▶", color: "#FF0000" },
274 tidal: { name: "Tidal", icon: "🌊", color: "#00FFFF" },
275 bandcamp: { name: "Bandcamp", icon: "△", color: "#1DA0C3" },
276 };
277
278 const availablePlatforms = songlinkData
279 ? Object.keys(platformConfig).filter((platform) =>
280 songlinkData.linksByPlatform[platform]
281 )
282 : [];
283
284 return (
285 <>
286 <div style={styles.container}>
287 {/* Album Artwork */}
288 <div style={styles.artworkContainer}>
289 {artworkLoading ? (
290 <div style={styles.artworkPlaceholder}>
291 <div style={styles.loadingSpinner} />
292 </div>
293 ) : albumArt ? (
294 <img
295 src={albumArt}
296 alt={`${item.releaseName || "Album"} cover`}
297 style={styles.artwork}
298 onError={(e) => {
299 console.error("Failed to load album art:", {
300 url: albumArt,
301 track: item.trackName,
302 artist: item.artists[0]?.artistName,
303 error: "Image load error"
304 });
305 e.currentTarget.style.display = "none";
306 }}
307 />
308 ) : (
309 <div style={styles.artworkPlaceholder}>
310 <svg
311 width="64"
312 height="64"
313 viewBox="0 0 24 24"
314 fill="none"
315 stroke="currentColor"
316 strokeWidth="1.5"
317 >
318 <circle cx="12" cy="12" r="10" />
319 <circle cx="12" cy="12" r="3" />
320 <path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
321 </svg>
322 </div>
323 )}
324 </div>
325
326 {/* Content */}
327 <div style={styles.content}>
328 <div style={styles.label}>{label}</div>
329 <h2 style={styles.trackName}>{item.trackName}</h2>
330 <div style={styles.artistName}>{artistNames}</div>
331 {item.releaseName && (
332 <div style={styles.releaseName}>from {item.releaseName}</div>
333 )}
334
335 {/* Listen Button */}
336 {availablePlatforms.length > 0 ? (
337 <button
338 onClick={() => setShowPlatformModal(true)}
339 style={styles.listenButton}
340 data-teal-listen-button="true"
341 >
342 <span>Listen with your Streaming Client</span>
343 <svg
344 width="16"
345 height="16"
346 viewBox="0 0 24 24"
347 fill="none"
348 stroke="currentColor"
349 strokeWidth="2"
350 strokeLinecap="round"
351 strokeLinejoin="round"
352 >
353 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
354 <polyline points="15 3 21 3 21 9" />
355 <line x1="10" y1="14" x2="21" y2="3" />
356 </svg>
357 </button>
358 ) : item.originUrl ? (
359 <a
360 href={item.originUrl}
361 target="_blank"
362 rel="noopener noreferrer"
363 style={styles.listenButton}
364 data-teal-listen-button="true"
365 >
366 <span>Listen on Last.fm</span>
367 <svg
368 width="16"
369 height="16"
370 viewBox="0 0 24 24"
371 fill="none"
372 stroke="currentColor"
373 strokeWidth="2"
374 strokeLinecap="round"
375 strokeLinejoin="round"
376 >
377 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
378 <polyline points="15 3 21 3 21 9" />
379 <line x1="10" y1="14" x2="21" y2="3" />
380 </svg>
381 </a>
382 ) : null}
383 </div>
384 </div>
385
386 {/* Platform Selection Modal */}
387 {showPlatformModal && songlinkData && (
388 <div style={styles.modalOverlay} onClick={() => setShowPlatformModal(false)}>
389 <div style={styles.modalContent} onClick={(e) => e.stopPropagation()}>
390 <div style={styles.modalHeader}>
391 <h3 style={styles.modalTitle}>Choose your streaming service</h3>
392 <button
393 style={styles.closeButton}
394 onClick={() => setShowPlatformModal(false)}
395 data-teal-close="true"
396 >
397 ×
398 </button>
399 </div>
400 <div style={styles.platformList}>
401 {availablePlatforms.map((platform) => {
402 const config = platformConfig[platform];
403 const link = songlinkData.linksByPlatform[platform];
404 return (
405 <a
406 key={platform}
407 href={link.url}
408 target="_blank"
409 rel="noopener noreferrer"
410 style={{
411 ...styles.platformItem,
412 borderLeft: `4px solid ${config.color}`,
413 }}
414 onClick={() => setShowPlatformModal(false)}
415 data-teal-platform="true"
416 >
417 <span style={styles.platformIcon}>{config.icon}</span>
418 <span style={styles.platformName}>{config.name}</span>
419 <svg
420 width="20"
421 height="20"
422 viewBox="0 0 24 24"
423 fill="none"
424 stroke="currentColor"
425 strokeWidth="2"
426 style={styles.platformArrow}
427 >
428 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
429 <polyline points="15 3 21 3 21 9" />
430 <line x1="10" y1="14" x2="21" y2="3" />
431 </svg>
432 </a>
433 );
434 })}
435 </div>
436 </div>
437 </div>
438 )}
439 </>
440 );
441};
442
443const styles: Record<string, React.CSSProperties> = {
444 container: {
445 fontFamily: "system-ui, -apple-system, sans-serif",
446 display: "flex",
447 flexDirection: "column",
448 background: "var(--atproto-color-bg)",
449 borderRadius: 16,
450 overflow: "hidden",
451 maxWidth: 420,
452 color: "var(--atproto-color-text)",
453 boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
454 border: "1px solid var(--atproto-color-border)",
455 },
456 artworkContainer: {
457 width: "100%",
458 aspectRatio: "1 / 1",
459 position: "relative",
460 overflow: "hidden",
461 },
462 artwork: {
463 width: "100%",
464 height: "100%",
465 objectFit: "cover",
466 display: "block",
467 },
468 artworkPlaceholder: {
469 width: "100%",
470 height: "100%",
471 display: "flex",
472 alignItems: "center",
473 justifyContent: "center",
474 background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
475 color: "rgba(255, 255, 255, 0.5)",
476 },
477 loadingSpinner: {
478 width: 40,
479 height: 40,
480 border: "3px solid var(--atproto-color-border)",
481 borderTop: "3px solid var(--atproto-color-primary)",
482 borderRadius: "50%",
483 animation: "spin 1s linear infinite",
484 },
485 content: {
486 padding: "24px",
487 display: "flex",
488 flexDirection: "column",
489 gap: "8px",
490 },
491 label: {
492 fontSize: 11,
493 fontWeight: 600,
494 letterSpacing: "0.1em",
495 textTransform: "uppercase",
496 color: "var(--atproto-color-text-secondary)",
497 marginBottom: "4px",
498 },
499 trackName: {
500 fontSize: 28,
501 fontWeight: 700,
502 margin: 0,
503 lineHeight: 1.2,
504 color: "var(--atproto-color-text)",
505 },
506 artistName: {
507 fontSize: 16,
508 color: "var(--atproto-color-text-secondary)",
509 marginTop: "4px",
510 },
511 releaseName: {
512 fontSize: 14,
513 color: "var(--atproto-color-text-secondary)",
514 marginTop: "2px",
515 },
516 listenButton: {
517 display: "inline-flex",
518 alignItems: "center",
519 gap: "8px",
520 marginTop: "16px",
521 padding: "12px 20px",
522 background: "var(--atproto-color-bg-elevated)",
523 border: "1px solid var(--atproto-color-border)",
524 borderRadius: 24,
525 color: "var(--atproto-color-text)",
526 fontSize: 14,
527 fontWeight: 600,
528 textDecoration: "none",
529 cursor: "pointer",
530 transition: "all 0.2s ease",
531 alignSelf: "flex-start",
532 },
533 modalOverlay: {
534 position: "fixed",
535 top: 0,
536 left: 0,
537 right: 0,
538 bottom: 0,
539 backgroundColor: "rgba(0, 0, 0, 0.85)",
540 display: "flex",
541 alignItems: "center",
542 justifyContent: "center",
543 zIndex: 9999,
544 backdropFilter: "blur(4px)",
545 },
546 modalContent: {
547 background: "var(--atproto-color-bg)",
548 borderRadius: 16,
549 padding: 0,
550 maxWidth: 450,
551 width: "90%",
552 maxHeight: "80vh",
553 overflow: "auto",
554 boxShadow: "0 20px 60px rgba(0, 0, 0, 0.8)",
555 border: "1px solid var(--atproto-color-border)",
556 },
557 modalHeader: {
558 display: "flex",
559 justifyContent: "space-between",
560 alignItems: "center",
561 padding: "24px 24px 16px 24px",
562 borderBottom: "1px solid var(--atproto-color-border)",
563 },
564 modalTitle: {
565 margin: 0,
566 fontSize: 20,
567 fontWeight: 700,
568 color: "var(--atproto-color-text)",
569 },
570 closeButton: {
571 background: "transparent",
572 border: "none",
573 color: "var(--atproto-color-text-secondary)",
574 fontSize: 32,
575 cursor: "pointer",
576 padding: 0,
577 width: 32,
578 height: 32,
579 display: "flex",
580 alignItems: "center",
581 justifyContent: "center",
582 borderRadius: "50%",
583 transition: "all 0.2s ease",
584 lineHeight: 1,
585 },
586 platformList: {
587 padding: "16px",
588 display: "flex",
589 flexDirection: "column",
590 gap: "8px",
591 },
592 platformItem: {
593 display: "flex",
594 alignItems: "center",
595 gap: "16px",
596 padding: "16px",
597 background: "var(--atproto-color-bg-hover)",
598 borderRadius: 12,
599 textDecoration: "none",
600 color: "var(--atproto-color-text)",
601 transition: "all 0.2s ease",
602 cursor: "pointer",
603 border: "1px solid var(--atproto-color-border)",
604 },
605 platformIcon: {
606 fontSize: 24,
607 width: 32,
608 height: 32,
609 display: "flex",
610 alignItems: "center",
611 justifyContent: "center",
612 },
613 platformName: {
614 flex: 1,
615 fontSize: 16,
616 fontWeight: 600,
617 },
618 platformArrow: {
619 opacity: 0.5,
620 transition: "opacity 0.2s ease",
621 },
622 notListeningContainer: {
623 fontFamily: "system-ui, -apple-system, sans-serif",
624 display: "flex",
625 flexDirection: "column",
626 alignItems: "center",
627 justifyContent: "center",
628 background: "var(--atproto-color-bg)",
629 borderRadius: 16,
630 padding: "80px 40px",
631 maxWidth: 420,
632 color: "var(--atproto-color-text-secondary)",
633 border: "1px solid var(--atproto-color-border)",
634 textAlign: "center",
635 },
636 notListeningIcon: {
637 width: 120,
638 height: 120,
639 borderRadius: "50%",
640 background: "var(--atproto-color-bg-elevated)",
641 display: "flex",
642 alignItems: "center",
643 justifyContent: "center",
644 marginBottom: 24,
645 color: "var(--atproto-color-text-muted)",
646 },
647 notListeningTitle: {
648 fontSize: 18,
649 fontWeight: 600,
650 color: "var(--atproto-color-text)",
651 marginBottom: 8,
652 },
653 notListeningSubtitle: {
654 fontSize: 14,
655 color: "var(--atproto-color-text-secondary)",
656 },
657};
658
659// Add keyframes and hover styles
660if (typeof document !== "undefined") {
661 const styleId = "teal-status-styles";
662 if (!document.getElementById(styleId)) {
663 const styleElement = document.createElement("style");
664 styleElement.id = styleId;
665 styleElement.textContent = `
666 @keyframes spin {
667 0% { transform: rotate(0deg); }
668 100% { transform: rotate(360deg); }
669 }
670
671 button[data-teal-listen-button]:hover:not(:disabled),
672 a[data-teal-listen-button]:hover {
673 background: var(--atproto-color-bg-pressed) !important;
674 border-color: var(--atproto-color-border-hover) !important;
675 transform: translateY(-2px);
676 }
677
678 button[data-teal-listen-button]:disabled {
679 opacity: 0.5;
680 cursor: not-allowed;
681 }
682
683 button[data-teal-close]:hover {
684 background: var(--atproto-color-bg-hover) !important;
685 color: var(--atproto-color-text) !important;
686 }
687
688 a[data-teal-platform]:hover {
689 background: var(--atproto-color-bg-pressed) !important;
690 transform: translateX(4px);
691 }
692
693 a[data-teal-platform]:hover svg {
694 opacity: 1 !important;
695 }
696 `;
697 document.head.appendChild(styleElement);
698 }
699}
700
701export default CurrentlyPlayingRenderer;