+648
lib/components/SongHistoryList.tsx
+648
lib/components/SongHistoryList.tsx
···
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";
5
+
6
+
/**
7
+
* Options for rendering a paginated list of song history from teal.fm.
8
+
*/
9
+
export 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
+
24
+
interface 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
+
*/
49
+
export 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
+
218
+
interface SongRowProps {
219
+
record: TealFeedPlayRecord;
220
+
hasDivider: boolean;
221
+
}
222
+
223
+
const 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
+
406
+
function formatDid(did: string) {
407
+
return did.replace(/^did:(plc:)?/, "");
408
+
}
409
+
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<{
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
+
435
+
const 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
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 = `
639
+
@keyframes spin {
640
+
0% { transform: rotate(0deg); }
641
+
100% { transform: rotate(360deg); }
642
+
}
643
+
`;
644
+
document.head.appendChild(styleElement);
645
+
}
646
+
}
647
+
648
+
export default SongHistoryList;
+1
lib/index.ts
+1
lib/index.ts
+16
src/App.tsx
+16
src/App.tsx
···
15
15
import { GrainGallery } from "../lib/components/GrainGallery";
16
16
import { CurrentlyPlaying } from "../lib/components/CurrentlyPlaying";
17
17
import { LastPlayed } from "../lib/components/LastPlayed";
18
+
import { SongHistoryList } from "../lib/components/SongHistoryList";
18
19
import { useDidResolution } from "../lib/hooks/useDidResolution";
19
20
import { useLatestRecord } from "../lib/hooks/useLatestRecord";
20
21
import type { FeedPostRecord } from "../lib/types/bluesky";
···
334
335
Most recent play from teal.fm feed
335
336
</p>
336
337
<LastPlayed did="nekomimi.pet" />
338
+
</section>
339
+
<section style={panelStyle}>
340
+
<h3 style={sectionHeaderStyle}>
341
+
teal.fm Song History
342
+
</h3>
343
+
<p
344
+
style={{
345
+
fontSize: 12,
346
+
color: `var(--demo-text-secondary)`,
347
+
margin: "0 0 8px",
348
+
}}
349
+
>
350
+
Listening history with album art focus
351
+
</p>
352
+
<SongHistoryList did="nekomimi.pet" limit={6} />
337
353
</section>
338
354
</div>
339
355
<div style={columnStackStyle}>