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

Configure Feed

Select the types of activity you want to include in your feed.

Add Song History Component

+665
+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}>