A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.

Add Song History Component

Changed files
+665
lib
src
+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
··· 18 18 export * from "./components/TangledString"; 19 19 export * from "./components/CurrentlyPlaying"; 20 20 export * from "./components/LastPlayed"; 21 + export * from "./components/SongHistoryList"; 21 22 22 23 // Hooks 23 24 export * from "./hooks/useAtProtoRecord";
+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}>