···
1
+
import React, { useState, useEffect, useMemo } from "react";
2
+
import { usePaginatedRecords } from "../hooks/usePaginatedRecords";
3
+
import { useDidResolution } from "../hooks/useDidResolution";
4
+
import type { TealFeedPlayRecord } from "../types/teal";
7
+
* Options for rendering a paginated list of song history from teal.fm.
9
+
export interface SongHistoryListProps {
11
+
* DID whose song history should be fetched.
15
+
* Maximum number of records to list per page. Defaults to `6`.
19
+
* Enables pagination controls when `true`. Defaults to `true`.
21
+
enablePagination?: boolean;
24
+
interface SonglinkResponse {
26
+
[platform: string]: {
28
+
entityUniqueId: string;
31
+
entitiesByUniqueId: {
33
+
thumbnailUrl?: string;
35
+
artistName?: string;
38
+
entityUniqueId?: string;
42
+
* Fetches a user's song history from teal.fm and renders them with album art focus.
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.
49
+
export const SongHistoryList: React.FC<SongHistoryListProps> = React.memo(({
52
+
enablePagination = true,
54
+
const { handle: resolvedHandle } = useDidResolution(did);
55
+
const actorLabel = resolvedHandle ?? formatDid(did);
67
+
} = usePaginatedRecords<TealFeedPlayRecord>({
69
+
collection: "fm.teal.alpha.feed.play",
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]);
83
+
<div role="alert" style={{ padding: 8, color: "crimson" }}>
84
+
Failed to load song history.
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}>
98
+
stroke="currentColor"
100
+
strokeLinecap="round"
101
+
strokeLinejoin="round"
103
+
<path d="M9 18V5l12-2v13" />
104
+
<circle cx="6" cy="18" r="3" />
105
+
<circle cx="18" cy="16" r="3" />
108
+
<div style={listStyles.headerText}>
109
+
<span style={listStyles.title}>Listening History</span>
112
+
...listStyles.subtitle,
113
+
color: `var(--atproto-color-text-secondary)`,
122
+
style={{ ...listStyles.pageMeta, color: `var(--atproto-color-text-secondary)` }}
128
+
<div style={listStyles.items}>
129
+
{loading && records.length === 0 && (
130
+
<div style={{ ...listStyles.empty, color: `var(--atproto-color-text-secondary)` }}>
134
+
{records.map((record, idx) => (
136
+
key={`${record.rkey}-${record.value.playedTime}`}
137
+
record={record.value}
138
+
hasDivider={idx < records.length - 1}
141
+
{!loading && records.length === 0 && (
142
+
<div style={{ ...listStyles.empty, color: `var(--atproto-color-text-secondary)` }}>
147
+
{enablePagination && (
148
+
<div style={{ ...listStyles.footer, borderTopColor: `var(--atproto-color-border)`, color: `var(--atproto-color-text)` }}>
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,
159
+
disabled={!hasPrev}
163
+
<div style={listStyles.pageChips}>
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)`,
176
+
{(hasNext || pagesCount > pageIndex + 1) && (
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)`,
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,
201
+
disabled={!hasNext}
207
+
{loading && records.length > 0 && (
209
+
style={{ ...listStyles.loadingBar, background: `var(--atproto-color-bg-elevated)`, color: `var(--atproto-color-text-secondary)` }}
218
+
interface SongRowProps {
219
+
record: TealFeedPlayRecord;
220
+
hasDivider: boolean;
223
+
const SongRow: React.FC<SongRowProps> = ({ record, hasDivider }) => {
224
+
const [albumArt, setAlbumArt] = useState<string | undefined>(undefined);
225
+
const [artLoading, setArtLoading] = useState(true);
227
+
const artistNames = record.artists.map((a) => a.artistName).join(", ");
228
+
const relative = record.playedTime
229
+
? formatRelativeTime(record.playedTime)
231
+
const absolute = record.playedTime
232
+
? new Date(record.playedTime).toLocaleString()
236
+
let cancelled = false;
237
+
setArtLoading(true);
238
+
setAlbumArt(undefined);
240
+
const fetchAlbumArt = async () => {
244
+
const response = await fetch(
245
+
`https://api.song.link/v1-alpha.1/links?platform=isrc&type=song&id=${encodeURIComponent(record.isrc)}&songIfSingle=true`
247
+
if (cancelled) return;
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);
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`;
265
+
const iTunesResponse = await fetch(iTunesSearchUrl);
266
+
if (cancelled) return;
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;
274
+
setAlbumArt(artworkUrl);
278
+
setArtLoading(false);
280
+
console.error(`Failed to fetch album art for "${record.trackName}":`, err);
281
+
setArtLoading(false);
290
+
}, [record.trackName, artistNames, record.isrc]);
296
+
color: `var(--atproto-color-text)`,
297
+
borderBottom: hasDivider
298
+
? `1px solid var(--atproto-color-border)`
302
+
{/* Album Art - Large and prominent */}
303
+
<div style={listStyles.albumArtContainer}>
305
+
<div style={listStyles.albumArtPlaceholder}>
306
+
<div style={listStyles.loadingSpinner} />
311
+
alt={`${record.releaseName || "Album"} cover`}
312
+
style={listStyles.albumArt}
314
+
e.currentTarget.style.display = "none";
315
+
const parent = e.currentTarget.parentElement;
317
+
const placeholder = document.createElement("div");
318
+
Object.assign(placeholder.style, listStyles.albumArtPlaceholder);
319
+
placeholder.innerHTML = `
323
+
viewBox="0 0 24 24"
325
+
stroke="currentColor"
328
+
<circle cx="12" cy="12" r="10" />
329
+
<circle cx="12" cy="12" r="3" />
330
+
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
333
+
parent.appendChild(placeholder);
338
+
<div style={listStyles.albumArtPlaceholder}>
342
+
viewBox="0 0 24 24"
344
+
stroke="currentColor"
347
+
<circle cx="12" cy="12" r="10" />
348
+
<circle cx="12" cy="12" r="3" />
349
+
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
356
+
<div style={listStyles.songInfo}>
357
+
<div style={listStyles.trackName}>{record.trackName}</div>
358
+
<div style={{ ...listStyles.artistName, color: `var(--atproto-color-text-secondary)` }}>
361
+
{record.releaseName && (
362
+
<div style={{ ...listStyles.releaseName, color: `var(--atproto-color-text-secondary)` }}>
363
+
{record.releaseName}
368
+
style={{ ...listStyles.playedTime, color: `var(--atproto-color-text-secondary)` }}
376
+
{/* External Link */}
377
+
{record.originUrl && (
379
+
href={record.originUrl}
381
+
rel="noopener noreferrer"
382
+
style={listStyles.externalLink}
383
+
title="Listen on streaming service"
384
+
aria-label={`Listen to ${record.trackName} by ${artistNames}`}
389
+
viewBox="0 0 24 24"
391
+
stroke="currentColor"
393
+
strokeLinecap="round"
394
+
strokeLinejoin="round"
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" />
406
+
function formatDid(did: string) {
407
+
return did.replace(/^did:(plc:)?/, "");
410
+
function 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<{
416
+
unit: Intl.RelativeTimeFormatUnit;
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 },
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);
435
+
const listStyles = {
438
+
borderWidth: "1px",
439
+
borderStyle: "solid",
440
+
borderColor: "transparent",
441
+
boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)",
442
+
overflow: "hidden",
444
+
flexDirection: "column",
445
+
} satisfies React.CSSProperties,
448
+
alignItems: "center",
449
+
justifyContent: "space-between",
450
+
padding: "14px 18px",
453
+
borderBottom: "1px solid var(--atproto-color-border)",
454
+
} satisfies React.CSSProperties,
457
+
alignItems: "center",
459
+
} satisfies React.CSSProperties,
464
+
alignItems: "center",
465
+
justifyContent: "center",
466
+
borderRadius: "50%",
467
+
color: "var(--atproto-color-text)",
468
+
} satisfies React.CSSProperties,
471
+
flexDirection: "column",
473
+
} satisfies React.CSSProperties,
477
+
} satisfies React.CSSProperties,
481
+
} satisfies React.CSSProperties,
484
+
} satisfies React.CSSProperties,
487
+
flexDirection: "column",
488
+
} satisfies React.CSSProperties,
490
+
padding: "24px 18px",
492
+
textAlign: "center",
493
+
} satisfies React.CSSProperties,
498
+
alignItems: "center",
499
+
transition: "background-color 120ms ease",
500
+
position: "relative",
501
+
} satisfies React.CSSProperties,
502
+
albumArtContainer: {
507
+
overflow: "hidden",
508
+
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
509
+
} satisfies React.CSSProperties,
513
+
objectFit: "cover",
515
+
} satisfies React.CSSProperties,
516
+
albumArtPlaceholder: {
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,
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,
536
+
flexDirection: "column",
539
+
} satisfies React.CSSProperties,
544
+
color: "var(--atproto-color-text)",
545
+
overflow: "hidden",
546
+
textOverflow: "ellipsis",
547
+
whiteSpace: "nowrap",
548
+
} satisfies React.CSSProperties,
552
+
overflow: "hidden",
553
+
textOverflow: "ellipsis",
554
+
whiteSpace: "nowrap",
555
+
} satisfies React.CSSProperties,
558
+
overflow: "hidden",
559
+
textOverflow: "ellipsis",
560
+
whiteSpace: "nowrap",
561
+
} satisfies React.CSSProperties,
566
+
} satisfies React.CSSProperties,
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)",
579
+
transition: "all 0.2s ease",
580
+
textDecoration: "none",
581
+
} satisfies React.CSSProperties,
584
+
alignItems: "center",
585
+
justifyContent: "space-between",
586
+
padding: "12px 18px",
587
+
borderTop: "1px solid transparent",
589
+
} satisfies React.CSSProperties,
593
+
alignItems: "center",
594
+
} satisfies React.CSSProperties,
596
+
padding: "4px 10px",
599
+
borderWidth: "1px",
600
+
borderStyle: "solid",
601
+
borderColor: "transparent",
602
+
} satisfies React.CSSProperties,
604
+
padding: "4px 10px",
608
+
borderWidth: "1px",
609
+
borderStyle: "solid",
610
+
borderColor: "transparent",
611
+
} satisfies React.CSSProperties,
615
+
padding: "6px 12px",
618
+
background: "transparent",
620
+
alignItems: "center",
622
+
transition: "background-color 120ms ease",
623
+
} satisfies React.CSSProperties,
625
+
padding: "4px 18px 14px",
627
+
textAlign: "right",
629
+
} satisfies React.CSSProperties,
632
+
// Add keyframes and hover styles
633
+
if (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 = `
640
+
0% { transform: rotate(0deg); }
641
+
100% { transform: rotate(360deg); }
644
+
document.head.appendChild(styleElement);
648
+
export default SongHistoryList;