A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useState, useEffect, useMemo } from "react";
2import { usePaginatedRecords } from "../hooks/usePaginatedRecords";
3import { useDidResolution } from "../hooks/useDidResolution";
4import type { TealFeedPlayRecord } from "../types/teal";
5
6/**
7 * Options for rendering a paginated list of song history from teal.fm.
8 */
9export interface SongHistoryListProps {
10 /**
11 * DID whose song history should be fetched.
12 */
13 did: string;
14 /**
15 * Maximum number of records to list per page. Defaults to `6`.
16 */
17 limit?: number;
18 /**
19 * Enables pagination controls when `true`. Defaults to `true`.
20 */
21 enablePagination?: boolean;
22}
23
24interface SonglinkResponse {
25 linksByPlatform: {
26 [platform: string]: {
27 url: string;
28 entityUniqueId: string;
29 };
30 };
31 entitiesByUniqueId: {
32 [id: string]: {
33 thumbnailUrl?: string;
34 title?: string;
35 artistName?: string;
36 };
37 };
38 entityUniqueId?: string;
39}
40
41/**
42 * Fetches a user's song history from teal.fm and renders them with album art focus.
43 *
44 * @param did - DID whose song history should be displayed.
45 * @param limit - Maximum number of songs per page. Default `6`.
46 * @param enablePagination - Whether pagination controls should render. Default `true`.
47 * @returns A card-like list element with loading, empty, and error handling.
48 */
49export const SongHistoryList: React.FC<SongHistoryListProps> = React.memo(({
50 did,
51 limit = 6,
52 enablePagination = true,
53}) => {
54 const { handle: resolvedHandle } = useDidResolution(did);
55 const actorLabel = resolvedHandle ?? formatDid(did);
56
57 const {
58 records,
59 loading,
60 error,
61 hasNext,
62 hasPrev,
63 loadNext,
64 loadPrev,
65 pageIndex,
66 pagesCount,
67 } = usePaginatedRecords<TealFeedPlayRecord>({
68 did,
69 collection: "fm.teal.alpha.feed.play",
70 limit,
71 });
72
73 const pageLabel = useMemo(() => {
74 const knownTotal = Math.max(pageIndex + 1, pagesCount);
75 if (!enablePagination) return undefined;
76 if (hasNext && knownTotal === pageIndex + 1)
77 return `${pageIndex + 1}/…`;
78 return `${pageIndex + 1}/${knownTotal}`;
79 }, [enablePagination, hasNext, pageIndex, pagesCount]);
80
81 if (error)
82 return (
83 <div role="alert" style={{ padding: 8, color: "crimson" }}>
84 Failed to load song history.
85 </div>
86 );
87
88 return (
89 <div style={{ ...listStyles.card, background: `var(--atproto-color-bg)`, borderWidth: "1px", borderStyle: "solid", borderColor: `var(--atproto-color-border)` }}>
90 <div style={{ ...listStyles.header, background: `var(--atproto-color-bg-elevated)`, color: `var(--atproto-color-text)` }}>
91 <div style={listStyles.headerInfo}>
92 <div style={listStyles.headerIcon}>
93 <svg
94 width="24"
95 height="24"
96 viewBox="0 0 24 24"
97 fill="none"
98 stroke="currentColor"
99 strokeWidth="2"
100 strokeLinecap="round"
101 strokeLinejoin="round"
102 >
103 <path d="M9 18V5l12-2v13" />
104 <circle cx="6" cy="18" r="3" />
105 <circle cx="18" cy="16" r="3" />
106 </svg>
107 </div>
108 <div style={listStyles.headerText}>
109 <span style={listStyles.title}>Listening History</span>
110 <span
111 style={{
112 ...listStyles.subtitle,
113 color: `var(--atproto-color-text-secondary)`,
114 }}
115 >
116 @{actorLabel}
117 </span>
118 </div>
119 </div>
120 {pageLabel && (
121 <span
122 style={{ ...listStyles.pageMeta, color: `var(--atproto-color-text-secondary)` }}
123 >
124 {pageLabel}
125 </span>
126 )}
127 </div>
128 <div style={listStyles.items}>
129 {loading && records.length === 0 && (
130 <div style={{ ...listStyles.empty, color: `var(--atproto-color-text-secondary)` }}>
131 Loading songs…
132 </div>
133 )}
134 {records.map((record, idx) => (
135 <SongRow
136 key={`${record.rkey}-${record.value.playedTime}`}
137 record={record.value}
138 hasDivider={idx < records.length - 1}
139 />
140 ))}
141 {!loading && records.length === 0 && (
142 <div style={{ ...listStyles.empty, color: `var(--atproto-color-text-secondary)` }}>
143 No songs found.
144 </div>
145 )}
146 </div>
147 {enablePagination && (
148 <div style={{ ...listStyles.footer, borderTopColor: `var(--atproto-color-border)`, color: `var(--atproto-color-text)` }}>
149 <button
150 type="button"
151 style={{
152 ...listStyles.pageButton,
153 background: `var(--atproto-color-button-bg)`,
154 color: `var(--atproto-color-button-text)`,
155 cursor: hasPrev ? "pointer" : "not-allowed",
156 opacity: hasPrev ? 1 : 0.5,
157 }}
158 onClick={loadPrev}
159 disabled={!hasPrev}
160 >
161 ‹ Prev
162 </button>
163 <div style={listStyles.pageChips}>
164 <span
165 style={{
166 ...listStyles.pageChipActive,
167 color: `var(--atproto-color-button-text)`,
168 background: `var(--atproto-color-button-bg)`,
169 borderWidth: "1px",
170 borderStyle: "solid",
171 borderColor: `var(--atproto-color-button-bg)`,
172 }}
173 >
174 {pageIndex + 1}
175 </span>
176 {(hasNext || pagesCount > pageIndex + 1) && (
177 <span
178 style={{
179 ...listStyles.pageChip,
180 color: `var(--atproto-color-text-secondary)`,
181 borderWidth: "1px",
182 borderStyle: "solid",
183 borderColor: `var(--atproto-color-border)`,
184 background: `var(--atproto-color-bg)`,
185 }}
186 >
187 {pageIndex + 2}
188 </span>
189 )}
190 </div>
191 <button
192 type="button"
193 style={{
194 ...listStyles.pageButton,
195 background: `var(--atproto-color-button-bg)`,
196 color: `var(--atproto-color-button-text)`,
197 cursor: hasNext ? "pointer" : "not-allowed",
198 opacity: hasNext ? 1 : 0.5,
199 }}
200 onClick={loadNext}
201 disabled={!hasNext}
202 >
203 Next ›
204 </button>
205 </div>
206 )}
207 {loading && records.length > 0 && (
208 <div
209 style={{ ...listStyles.loadingBar, background: `var(--atproto-color-bg-elevated)`, color: `var(--atproto-color-text-secondary)` }}
210 >
211 Updating…
212 </div>
213 )}
214 </div>
215 );
216});
217
218interface SongRowProps {
219 record: TealFeedPlayRecord;
220 hasDivider: boolean;
221}
222
223const SongRow: React.FC<SongRowProps> = ({ record, hasDivider }) => {
224 const [albumArt, setAlbumArt] = useState<string | undefined>(undefined);
225 const [artLoading, setArtLoading] = useState(true);
226
227 const artistNames = record.artists.map((a) => a.artistName).join(", ");
228 const relative = record.playedTime
229 ? formatRelativeTime(record.playedTime)
230 : undefined;
231 const absolute = record.playedTime
232 ? new Date(record.playedTime).toLocaleString()
233 : undefined;
234
235 useEffect(() => {
236 let cancelled = false;
237 setArtLoading(true);
238 setAlbumArt(undefined);
239
240 const fetchAlbumArt = async () => {
241 try {
242 // Try ISRC first
243 if (record.isrc) {
244 const response = await fetch(
245 `https://api.song.link/v1-alpha.1/links?platform=isrc&type=song&id=${encodeURIComponent(record.isrc)}&songIfSingle=true`
246 );
247 if (cancelled) return;
248 if (response.ok) {
249 const data: SonglinkResponse = await response.json();
250 const entityId = data.entityUniqueId;
251 const entity = entityId ? data.entitiesByUniqueId?.[entityId] : undefined;
252 if (entity?.thumbnailUrl) {
253 setAlbumArt(entity.thumbnailUrl);
254 setArtLoading(false);
255 return;
256 }
257 }
258 }
259
260 // Fallback to iTunes search
261 const iTunesSearchUrl = `https://itunes.apple.com/search?term=${encodeURIComponent(
262 `${record.trackName} ${artistNames}`
263 )}&media=music&entity=song&limit=1`;
264
265 const iTunesResponse = await fetch(iTunesSearchUrl);
266 if (cancelled) return;
267
268 if (iTunesResponse.ok) {
269 const iTunesData = await iTunesResponse.json();
270 if (iTunesData.results && iTunesData.results.length > 0) {
271 const match = iTunesData.results[0];
272 const artworkUrl = match.artworkUrl100?.replace('100x100', '600x600') || match.artworkUrl100;
273 if (artworkUrl) {
274 setAlbumArt(artworkUrl);
275 }
276 }
277 }
278 setArtLoading(false);
279 } catch (err) {
280 console.error(`Failed to fetch album art for "${record.trackName}":`, err);
281 setArtLoading(false);
282 }
283 };
284
285 fetchAlbumArt();
286
287 return () => {
288 cancelled = true;
289 };
290 }, [record.trackName, artistNames, record.isrc]);
291
292 return (
293 <div
294 style={{
295 ...listStyles.row,
296 color: `var(--atproto-color-text)`,
297 borderBottom: hasDivider
298 ? `1px solid var(--atproto-color-border)`
299 : "none",
300 }}
301 >
302 {/* Album Art - Large and prominent */}
303 <div style={listStyles.albumArtContainer}>
304 {artLoading ? (
305 <div style={listStyles.albumArtPlaceholder}>
306 <div style={listStyles.loadingSpinner} />
307 </div>
308 ) : albumArt ? (
309 <img
310 src={albumArt}
311 alt={`${record.releaseName || "Album"} cover`}
312 style={listStyles.albumArt}
313 onError={(e) => {
314 e.currentTarget.style.display = "none";
315 const parent = e.currentTarget.parentElement;
316 if (parent) {
317 const placeholder = document.createElement("div");
318 Object.assign(placeholder.style, listStyles.albumArtPlaceholder);
319 placeholder.innerHTML = `
320 <svg
321 width="48"
322 height="48"
323 viewBox="0 0 24 24"
324 fill="none"
325 stroke="currentColor"
326 stroke-width="1.5"
327 >
328 <circle cx="12" cy="12" r="10" />
329 <circle cx="12" cy="12" r="3" />
330 <path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
331 </svg>
332 `;
333 parent.appendChild(placeholder);
334 }
335 }}
336 />
337 ) : (
338 <div style={listStyles.albumArtPlaceholder}>
339 <svg
340 width="48"
341 height="48"
342 viewBox="0 0 24 24"
343 fill="none"
344 stroke="currentColor"
345 strokeWidth="1.5"
346 >
347 <circle cx="12" cy="12" r="10" />
348 <circle cx="12" cy="12" r="3" />
349 <path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
350 </svg>
351 </div>
352 )}
353 </div>
354
355 {/* Song Info */}
356 <div style={listStyles.songInfo}>
357 <div style={listStyles.trackName}>{record.trackName}</div>
358 <div style={{ ...listStyles.artistName, color: `var(--atproto-color-text-secondary)` }}>
359 {artistNames}
360 </div>
361 {record.releaseName && (
362 <div style={{ ...listStyles.releaseName, color: `var(--atproto-color-text-secondary)` }}>
363 {record.releaseName}
364 </div>
365 )}
366 {relative && (
367 <div
368 style={{ ...listStyles.playedTime, color: `var(--atproto-color-text-secondary)` }}
369 title={absolute}
370 >
371 {relative}
372 </div>
373 )}
374 </div>
375
376 {/* External Link */}
377 {record.originUrl && (
378 <a
379 href={record.originUrl}
380 target="_blank"
381 rel="noopener noreferrer"
382 style={listStyles.externalLink}
383 title="Listen on streaming service"
384 aria-label={`Listen to ${record.trackName} by ${artistNames}`}
385 >
386 <svg
387 width="20"
388 height="20"
389 viewBox="0 0 24 24"
390 fill="none"
391 stroke="currentColor"
392 strokeWidth="2"
393 strokeLinecap="round"
394 strokeLinejoin="round"
395 >
396 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
397 <polyline points="15 3 21 3 21 9" />
398 <line x1="10" y1="14" x2="21" y2="3" />
399 </svg>
400 </a>
401 )}
402 </div>
403 );
404};
405
406function formatDid(did: string) {
407 return did.replace(/^did:(plc:)?/, "");
408}
409
410function formatRelativeTime(iso: string): string {
411 const date = new Date(iso);
412 const diffSeconds = (date.getTime() - Date.now()) / 1000;
413 const absSeconds = Math.abs(diffSeconds);
414 const thresholds: Array<{
415 limit: number;
416 unit: Intl.RelativeTimeFormatUnit;
417 divisor: number;
418 }> = [
419 { limit: 60, unit: "second", divisor: 1 },
420 { limit: 3600, unit: "minute", divisor: 60 },
421 { limit: 86400, unit: "hour", divisor: 3600 },
422 { limit: 604800, unit: "day", divisor: 86400 },
423 { limit: 2629800, unit: "week", divisor: 604800 },
424 { limit: 31557600, unit: "month", divisor: 2629800 },
425 { limit: Infinity, unit: "year", divisor: 31557600 },
426 ];
427 const threshold =
428 thresholds.find((t) => absSeconds < t.limit) ??
429 thresholds[thresholds.length - 1];
430 const value = diffSeconds / threshold.divisor;
431 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
432 return rtf.format(Math.round(value), threshold.unit);
433}
434
435const listStyles = {
436 card: {
437 borderRadius: 16,
438 borderWidth: "1px",
439 borderStyle: "solid",
440 borderColor: "transparent",
441 boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)",
442 overflow: "hidden",
443 display: "flex",
444 flexDirection: "column",
445 } satisfies React.CSSProperties,
446 header: {
447 display: "flex",
448 alignItems: "center",
449 justifyContent: "space-between",
450 padding: "14px 18px",
451 fontSize: 14,
452 fontWeight: 500,
453 borderBottom: "1px solid var(--atproto-color-border)",
454 } satisfies React.CSSProperties,
455 headerInfo: {
456 display: "flex",
457 alignItems: "center",
458 gap: 12,
459 } satisfies React.CSSProperties,
460 headerIcon: {
461 width: 28,
462 height: 28,
463 display: "flex",
464 alignItems: "center",
465 justifyContent: "center",
466 borderRadius: "50%",
467 color: "var(--atproto-color-text)",
468 } satisfies React.CSSProperties,
469 headerText: {
470 display: "flex",
471 flexDirection: "column",
472 gap: 2,
473 } satisfies React.CSSProperties,
474 title: {
475 fontSize: 15,
476 fontWeight: 600,
477 } satisfies React.CSSProperties,
478 subtitle: {
479 fontSize: 12,
480 fontWeight: 500,
481 } satisfies React.CSSProperties,
482 pageMeta: {
483 fontSize: 12,
484 } satisfies React.CSSProperties,
485 items: {
486 display: "flex",
487 flexDirection: "column",
488 } satisfies React.CSSProperties,
489 empty: {
490 padding: "24px 18px",
491 fontSize: 13,
492 textAlign: "center",
493 } satisfies React.CSSProperties,
494 row: {
495 padding: "18px",
496 display: "flex",
497 gap: 16,
498 alignItems: "center",
499 transition: "background-color 120ms ease",
500 position: "relative",
501 } satisfies React.CSSProperties,
502 albumArtContainer: {
503 width: 96,
504 height: 96,
505 flexShrink: 0,
506 borderRadius: 8,
507 overflow: "hidden",
508 boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
509 } satisfies React.CSSProperties,
510 albumArt: {
511 width: "100%",
512 height: "100%",
513 objectFit: "cover",
514 display: "block",
515 } satisfies React.CSSProperties,
516 albumArtPlaceholder: {
517 width: "100%",
518 height: "100%",
519 display: "flex",
520 alignItems: "center",
521 justifyContent: "center",
522 background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
523 color: "rgba(255, 255, 255, 0.6)",
524 } satisfies React.CSSProperties,
525 loadingSpinner: {
526 width: 28,
527 height: 28,
528 border: "3px solid rgba(255, 255, 255, 0.3)",
529 borderTop: "3px solid rgba(255, 255, 255, 0.9)",
530 borderRadius: "50%",
531 animation: "spin 1s linear infinite",
532 } satisfies React.CSSProperties,
533 songInfo: {
534 flex: 1,
535 display: "flex",
536 flexDirection: "column",
537 gap: 4,
538 minWidth: 0,
539 } satisfies React.CSSProperties,
540 trackName: {
541 fontSize: 16,
542 fontWeight: 600,
543 lineHeight: 1.3,
544 color: "var(--atproto-color-text)",
545 overflow: "hidden",
546 textOverflow: "ellipsis",
547 whiteSpace: "nowrap",
548 } satisfies React.CSSProperties,
549 artistName: {
550 fontSize: 14,
551 fontWeight: 500,
552 overflow: "hidden",
553 textOverflow: "ellipsis",
554 whiteSpace: "nowrap",
555 } satisfies React.CSSProperties,
556 releaseName: {
557 fontSize: 13,
558 overflow: "hidden",
559 textOverflow: "ellipsis",
560 whiteSpace: "nowrap",
561 } satisfies React.CSSProperties,
562 playedTime: {
563 fontSize: 12,
564 fontWeight: 500,
565 marginTop: 2,
566 } satisfies React.CSSProperties,
567 externalLink: {
568 flexShrink: 0,
569 width: 36,
570 height: 36,
571 display: "flex",
572 alignItems: "center",
573 justifyContent: "center",
574 borderRadius: "50%",
575 background: "var(--atproto-color-bg-elevated)",
576 border: "1px solid var(--atproto-color-border)",
577 color: "var(--atproto-color-text-secondary)",
578 cursor: "pointer",
579 transition: "all 0.2s ease",
580 textDecoration: "none",
581 } satisfies React.CSSProperties,
582 footer: {
583 display: "flex",
584 alignItems: "center",
585 justifyContent: "space-between",
586 padding: "12px 18px",
587 borderTop: "1px solid transparent",
588 fontSize: 13,
589 } satisfies React.CSSProperties,
590 pageChips: {
591 display: "flex",
592 gap: 6,
593 alignItems: "center",
594 } satisfies React.CSSProperties,
595 pageChip: {
596 padding: "4px 10px",
597 borderRadius: 999,
598 fontSize: 13,
599 borderWidth: "1px",
600 borderStyle: "solid",
601 borderColor: "transparent",
602 } satisfies React.CSSProperties,
603 pageChipActive: {
604 padding: "4px 10px",
605 borderRadius: 999,
606 fontSize: 13,
607 fontWeight: 600,
608 borderWidth: "1px",
609 borderStyle: "solid",
610 borderColor: "transparent",
611 } satisfies React.CSSProperties,
612 pageButton: {
613 border: "none",
614 borderRadius: 999,
615 padding: "6px 12px",
616 fontSize: 13,
617 fontWeight: 500,
618 background: "transparent",
619 display: "flex",
620 alignItems: "center",
621 gap: 4,
622 transition: "background-color 120ms ease",
623 } satisfies React.CSSProperties,
624 loadingBar: {
625 padding: "4px 18px 14px",
626 fontSize: 12,
627 textAlign: "right",
628 color: "#64748b",
629 } satisfies React.CSSProperties,
630};
631
632// Add keyframes and hover styles
633if (typeof document !== "undefined") {
634 const styleId = "song-history-styles";
635 if (!document.getElementById(styleId)) {
636 const styleElement = document.createElement("style");
637 styleElement.id = styleId;
638 styleElement.textContent = `
639 @keyframes spin {
640 0% { transform: rotate(0deg); }
641 100% { transform: rotate(360deg); }
642 }
643 `;
644 document.head.appendChild(styleElement);
645 }
646}
647
648export default SongHistoryList;