A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
at main 17 kB view raw
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;