A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useState, useEffect } from "react"; 2import type { TealActorStatusRecord } from "../types/teal"; 3 4export interface CurrentlyPlayingRendererProps { 5 record: TealActorStatusRecord; 6 error?: Error; 7 loading: boolean; 8 did: string; 9 rkey: string; 10 colorScheme?: "light" | "dark" | "system"; 11 autoRefresh?: boolean; 12 /** Label to display (e.g., "CURRENTLY PLAYING", "LAST PLAYED"). Defaults to "CURRENTLY PLAYING". */ 13 label?: string; 14 /** Refresh interval in milliseconds. Defaults to 15000 (15 seconds). */ 15 refreshInterval?: number; 16 /** Handle to display in not listening state */ 17 handle?: string; 18} 19 20interface SonglinkPlatform { 21 url: string; 22 entityUniqueId: string; 23 nativeAppUriMobile?: string; 24 nativeAppUriDesktop?: string; 25} 26 27interface SonglinkResponse { 28 linksByPlatform: { 29 [platform: string]: SonglinkPlatform; 30 }; 31 entitiesByUniqueId: { 32 [id: string]: { 33 thumbnailUrl?: string; 34 title?: string; 35 artistName?: string; 36 }; 37 }; 38} 39 40export const CurrentlyPlayingRenderer: React.FC<CurrentlyPlayingRendererProps> = ({ 41 record, 42 error, 43 loading, 44 autoRefresh = true, 45 label = "CURRENTLY PLAYING", 46 refreshInterval = 15000, 47 handle, 48}) => { 49 const [albumArt, setAlbumArt] = useState<string | undefined>(undefined); 50 const [artworkLoading, setArtworkLoading] = useState(true); 51 const [songlinkData, setSonglinkData] = useState<SonglinkResponse | undefined>(undefined); 52 const [showPlatformModal, setShowPlatformModal] = useState(false); 53 const [refreshKey, setRefreshKey] = useState(0); 54 55 // Auto-refresh interval 56 useEffect(() => { 57 if (!autoRefresh) return; 58 59 const interval = setInterval(() => { 60 // Reset loading state before refresh 61 setArtworkLoading(true); 62 setRefreshKey((prev) => prev + 1); 63 }, refreshInterval); 64 65 return () => clearInterval(interval); 66 }, [autoRefresh, refreshInterval]); 67 68 useEffect(() => { 69 if (!record) return; 70 71 const { item } = record; 72 const artistName = item.artists[0]?.artistName; 73 const trackName = item.trackName; 74 75 if (!artistName || !trackName) { 76 setArtworkLoading(false); 77 return; 78 } 79 80 // Reset loading state at start of fetch 81 if (refreshKey > 0) { 82 setArtworkLoading(true); 83 } 84 85 let cancelled = false; 86 87 const fetchMusicData = async () => { 88 try { 89 // Step 1: Check if we have an ISRC - Songlink supports this directly 90 if (item.isrc) { 91 console.log(`[teal.fm] Attempting ISRC lookup for ${trackName} by ${artistName}`, { isrc: item.isrc }); 92 const response = await fetch( 93 `https://api.song.link/v1-alpha.1/links?platform=isrc&type=song&id=${encodeURIComponent(item.isrc)}&songIfSingle=true` 94 ); 95 if (cancelled) return; 96 if (response.ok) { 97 const data = await response.json(); 98 setSonglinkData(data); 99 100 // Extract album art from Songlink data 101 const entityId = data.entityUniqueId; 102 const entity = data.entitiesByUniqueId?.[entityId]; 103 if (entity?.thumbnailUrl) { 104 console.log(`[teal.fm] ✓ Found album art via ISRC lookup`); 105 setAlbumArt(entity.thumbnailUrl); 106 } else { 107 console.warn(`[teal.fm] ISRC lookup succeeded but no thumbnail found`); 108 } 109 setArtworkLoading(false); 110 return; 111 } else { 112 console.warn(`[teal.fm] ISRC lookup failed with status ${response.status}`); 113 } 114 } 115 116 // Step 2: Search iTunes Search API to find the track (single request for both artwork and links) 117 console.log(`[teal.fm] Attempting iTunes search for: "${trackName}" by "${artistName}"`); 118 const iTunesSearchUrl = `https://itunes.apple.com/search?term=${encodeURIComponent( 119 `${trackName} ${artistName}` 120 )}&media=music&entity=song&limit=1`; 121 122 const iTunesResponse = await fetch(iTunesSearchUrl); 123 124 if (cancelled) return; 125 126 if (iTunesResponse.ok) { 127 const iTunesData = await iTunesResponse.json(); 128 129 if (iTunesData.results && iTunesData.results.length > 0) { 130 const match = iTunesData.results[0]; 131 const iTunesId = match.trackId; 132 133 // Set album artwork immediately (600x600 for high quality) 134 const artworkUrl = match.artworkUrl100?.replace('100x100', '600x600') || match.artworkUrl100; 135 if (artworkUrl) { 136 console.log(`[teal.fm] ✓ Found album art via iTunes search`, { url: artworkUrl }); 137 setAlbumArt(artworkUrl); 138 } else { 139 console.warn(`[teal.fm] iTunes match found but no artwork URL`); 140 } 141 setArtworkLoading(false); 142 143 // Step 3: Use iTunes ID with Songlink to get all platform links 144 console.log(`[teal.fm] Fetching platform links via Songlink (iTunes ID: ${iTunesId})`); 145 const songlinkResponse = await fetch( 146 `https://api.song.link/v1-alpha.1/links?platform=itunes&type=song&id=${iTunesId}&songIfSingle=true` 147 ); 148 149 if (cancelled) return; 150 151 if (songlinkResponse.ok) { 152 const songlinkData = await songlinkResponse.json(); 153 console.log(`[teal.fm] ✓ Got platform links from Songlink`); 154 setSonglinkData(songlinkData); 155 return; 156 } else { 157 console.warn(`[teal.fm] Songlink request failed with status ${songlinkResponse.status}`); 158 } 159 } else { 160 console.warn(`[teal.fm] No iTunes results found for "${trackName}" by "${artistName}"`); 161 setArtworkLoading(false); 162 } 163 } else { 164 console.warn(`[teal.fm] iTunes search failed with status ${iTunesResponse.status}`); 165 } 166 167 // Step 4: Fallback - if originUrl is from a supported platform, try it directly 168 if (item.originUrl && ( 169 item.originUrl.includes('spotify.com') || 170 item.originUrl.includes('apple.com') || 171 item.originUrl.includes('youtube.com') || 172 item.originUrl.includes('tidal.com') 173 )) { 174 console.log(`[teal.fm] Attempting Songlink lookup via originUrl`, { url: item.originUrl }); 175 const songlinkResponse = await fetch( 176 `https://api.song.link/v1-alpha.1/links?url=${encodeURIComponent(item.originUrl)}&songIfSingle=true` 177 ); 178 179 if (cancelled) return; 180 181 if (songlinkResponse.ok) { 182 const data = await songlinkResponse.json(); 183 console.log(`[teal.fm] ✓ Got data from Songlink via originUrl`); 184 setSonglinkData(data); 185 186 // Try to get artwork from Songlink if we don't have it yet 187 if (!albumArt) { 188 const entityId = data.entityUniqueId; 189 const entity = data.entitiesByUniqueId?.[entityId]; 190 if (entity?.thumbnailUrl) { 191 console.log(`[teal.fm] ✓ Found album art via Songlink originUrl lookup`); 192 setAlbumArt(entity.thumbnailUrl); 193 } else { 194 console.warn(`[teal.fm] Songlink lookup succeeded but no thumbnail found`); 195 } 196 } 197 } else { 198 console.warn(`[teal.fm] Songlink originUrl lookup failed with status ${songlinkResponse.status}`); 199 } 200 } 201 202 if (!albumArt) { 203 console.warn(`[teal.fm] ✗ All album art fetch methods failed for "${trackName}" by "${artistName}"`); 204 } 205 206 setArtworkLoading(false); 207 } catch (err) { 208 console.error(`[teal.fm] ✗ Error fetching music data for "${trackName}" by "${artistName}":`, err); 209 setArtworkLoading(false); 210 } 211 }; 212 213 fetchMusicData(); 214 215 return () => { 216 cancelled = true; 217 }; 218 }, [record, refreshKey]); // Add refreshKey to trigger refetch 219 220 if (error) 221 return ( 222 <div style={{ padding: 8, color: "var(--atproto-color-error)" }}> 223 Failed to load status. 224 </div> 225 ); 226 if (loading && !record) 227 return ( 228 <div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}> 229 Loading 230 </div> 231 ); 232 233 const { item } = record; 234 235 // Check if user is not listening to anything 236 const isNotListening = !item.trackName || item.artists.length === 0; 237 238 // Show "not listening" state 239 if (isNotListening) { 240 const displayHandle = handle || "User"; 241 return ( 242 <div style={styles.notListeningContainer}> 243 <div style={styles.notListeningIcon}> 244 <svg 245 width="80" 246 height="80" 247 viewBox="0 0 24 24" 248 fill="none" 249 stroke="currentColor" 250 strokeWidth="1.5" 251 strokeLinecap="round" 252 strokeLinejoin="round" 253 > 254 <path d="M9 18V5l12-2v13" /> 255 <circle cx="6" cy="18" r="3" /> 256 <circle cx="18" cy="16" r="3" /> 257 </svg> 258 </div> 259 <div style={styles.notListeningTitle}> 260 {displayHandle} isn't listening to anything 261 </div> 262 <div style={styles.notListeningSubtitle}>Check back soon</div> 263 </div> 264 ); 265 } 266 267 const artistNames = item.artists.map((a) => a.artistName).join(", "); 268 269 const platformConfig: Record<string, { name: string; icon: string; color: string }> = { 270 spotify: { name: "Spotify", icon: "♫", color: "#1DB954" }, 271 appleMusic: { name: "Apple Music", icon: "🎵", color: "#FA243C" }, 272 youtube: { name: "YouTube", icon: "▶", color: "#FF0000" }, 273 youtubeMusic: { name: "YouTube Music", icon: "▶", color: "#FF0000" }, 274 tidal: { name: "Tidal", icon: "🌊", color: "#00FFFF" }, 275 bandcamp: { name: "Bandcamp", icon: "△", color: "#1DA0C3" }, 276 }; 277 278 const availablePlatforms = songlinkData 279 ? Object.keys(platformConfig).filter((platform) => 280 songlinkData.linksByPlatform[platform] 281 ) 282 : []; 283 284 return ( 285 <> 286 <div style={styles.container}> 287 {/* Album Artwork */} 288 <div style={styles.artworkContainer}> 289 {artworkLoading ? ( 290 <div style={styles.artworkPlaceholder}> 291 <div style={styles.loadingSpinner} /> 292 </div> 293 ) : albumArt ? ( 294 <img 295 src={albumArt} 296 alt={`${item.releaseName || "Album"} cover`} 297 style={styles.artwork} 298 onError={(e) => { 299 console.error("Failed to load album art:", { 300 url: albumArt, 301 track: item.trackName, 302 artist: item.artists[0]?.artistName, 303 error: "Image load error" 304 }); 305 e.currentTarget.style.display = "none"; 306 }} 307 /> 308 ) : ( 309 <div style={styles.artworkPlaceholder}> 310 <svg 311 width="64" 312 height="64" 313 viewBox="0 0 24 24" 314 fill="none" 315 stroke="currentColor" 316 strokeWidth="1.5" 317 > 318 <circle cx="12" cy="12" r="10" /> 319 <circle cx="12" cy="12" r="3" /> 320 <path d="M12 2v3M12 19v3M2 12h3M19 12h3" /> 321 </svg> 322 </div> 323 )} 324 </div> 325 326 {/* Content */} 327 <div style={styles.content}> 328 <div style={styles.label}>{label}</div> 329 <h2 style={styles.trackName}>{item.trackName}</h2> 330 <div style={styles.artistName}>{artistNames}</div> 331 {item.releaseName && ( 332 <div style={styles.releaseName}>from {item.releaseName}</div> 333 )} 334 335 {/* Listen Button */} 336 {availablePlatforms.length > 0 ? ( 337 <button 338 onClick={() => setShowPlatformModal(true)} 339 style={styles.listenButton} 340 data-teal-listen-button="true" 341 > 342 <span>Listen with your Streaming Client</span> 343 <svg 344 width="16" 345 height="16" 346 viewBox="0 0 24 24" 347 fill="none" 348 stroke="currentColor" 349 strokeWidth="2" 350 strokeLinecap="round" 351 strokeLinejoin="round" 352 > 353 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> 354 <polyline points="15 3 21 3 21 9" /> 355 <line x1="10" y1="14" x2="21" y2="3" /> 356 </svg> 357 </button> 358 ) : item.originUrl ? ( 359 <a 360 href={item.originUrl} 361 target="_blank" 362 rel="noopener noreferrer" 363 style={styles.listenButton} 364 data-teal-listen-button="true" 365 > 366 <span>Listen on Last.fm</span> 367 <svg 368 width="16" 369 height="16" 370 viewBox="0 0 24 24" 371 fill="none" 372 stroke="currentColor" 373 strokeWidth="2" 374 strokeLinecap="round" 375 strokeLinejoin="round" 376 > 377 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> 378 <polyline points="15 3 21 3 21 9" /> 379 <line x1="10" y1="14" x2="21" y2="3" /> 380 </svg> 381 </a> 382 ) : null} 383 </div> 384 </div> 385 386 {/* Platform Selection Modal */} 387 {showPlatformModal && songlinkData && ( 388 <div style={styles.modalOverlay} onClick={() => setShowPlatformModal(false)}> 389 <div style={styles.modalContent} onClick={(e) => e.stopPropagation()}> 390 <div style={styles.modalHeader}> 391 <h3 style={styles.modalTitle}>Choose your streaming service</h3> 392 <button 393 style={styles.closeButton} 394 onClick={() => setShowPlatformModal(false)} 395 data-teal-close="true" 396 > 397 × 398 </button> 399 </div> 400 <div style={styles.platformList}> 401 {availablePlatforms.map((platform) => { 402 const config = platformConfig[platform]; 403 const link = songlinkData.linksByPlatform[platform]; 404 return ( 405 <a 406 key={platform} 407 href={link.url} 408 target="_blank" 409 rel="noopener noreferrer" 410 style={{ 411 ...styles.platformItem, 412 borderLeft: `4px solid ${config.color}`, 413 }} 414 onClick={() => setShowPlatformModal(false)} 415 data-teal-platform="true" 416 > 417 <span style={styles.platformIcon}>{config.icon}</span> 418 <span style={styles.platformName}>{config.name}</span> 419 <svg 420 width="20" 421 height="20" 422 viewBox="0 0 24 24" 423 fill="none" 424 stroke="currentColor" 425 strokeWidth="2" 426 style={styles.platformArrow} 427 > 428 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> 429 <polyline points="15 3 21 3 21 9" /> 430 <line x1="10" y1="14" x2="21" y2="3" /> 431 </svg> 432 </a> 433 ); 434 })} 435 </div> 436 </div> 437 </div> 438 )} 439 </> 440 ); 441}; 442 443const styles: Record<string, React.CSSProperties> = { 444 container: { 445 fontFamily: "system-ui, -apple-system, sans-serif", 446 display: "flex", 447 flexDirection: "column", 448 background: "var(--atproto-color-bg)", 449 borderRadius: 16, 450 overflow: "hidden", 451 maxWidth: 420, 452 color: "var(--atproto-color-text)", 453 boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)", 454 border: "1px solid var(--atproto-color-border)", 455 }, 456 artworkContainer: { 457 width: "100%", 458 aspectRatio: "1 / 1", 459 position: "relative", 460 overflow: "hidden", 461 }, 462 artwork: { 463 width: "100%", 464 height: "100%", 465 objectFit: "cover", 466 display: "block", 467 }, 468 artworkPlaceholder: { 469 width: "100%", 470 height: "100%", 471 display: "flex", 472 alignItems: "center", 473 justifyContent: "center", 474 background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", 475 color: "rgba(255, 255, 255, 0.5)", 476 }, 477 loadingSpinner: { 478 width: 40, 479 height: 40, 480 border: "3px solid var(--atproto-color-border)", 481 borderTop: "3px solid var(--atproto-color-primary)", 482 borderRadius: "50%", 483 animation: "spin 1s linear infinite", 484 }, 485 content: { 486 padding: "24px", 487 display: "flex", 488 flexDirection: "column", 489 gap: "8px", 490 }, 491 label: { 492 fontSize: 11, 493 fontWeight: 600, 494 letterSpacing: "0.1em", 495 textTransform: "uppercase", 496 color: "var(--atproto-color-text-secondary)", 497 marginBottom: "4px", 498 }, 499 trackName: { 500 fontSize: 28, 501 fontWeight: 700, 502 margin: 0, 503 lineHeight: 1.2, 504 color: "var(--atproto-color-text)", 505 }, 506 artistName: { 507 fontSize: 16, 508 color: "var(--atproto-color-text-secondary)", 509 marginTop: "4px", 510 }, 511 releaseName: { 512 fontSize: 14, 513 color: "var(--atproto-color-text-secondary)", 514 marginTop: "2px", 515 }, 516 listenButton: { 517 display: "inline-flex", 518 alignItems: "center", 519 gap: "8px", 520 marginTop: "16px", 521 padding: "12px 20px", 522 background: "var(--atproto-color-bg-elevated)", 523 border: "1px solid var(--atproto-color-border)", 524 borderRadius: 24, 525 color: "var(--atproto-color-text)", 526 fontSize: 14, 527 fontWeight: 600, 528 textDecoration: "none", 529 cursor: "pointer", 530 transition: "all 0.2s ease", 531 alignSelf: "flex-start", 532 }, 533 modalOverlay: { 534 position: "fixed", 535 top: 0, 536 left: 0, 537 right: 0, 538 bottom: 0, 539 backgroundColor: "rgba(0, 0, 0, 0.85)", 540 display: "flex", 541 alignItems: "center", 542 justifyContent: "center", 543 zIndex: 9999, 544 backdropFilter: "blur(4px)", 545 }, 546 modalContent: { 547 background: "var(--atproto-color-bg)", 548 borderRadius: 16, 549 padding: 0, 550 maxWidth: 450, 551 width: "90%", 552 maxHeight: "80vh", 553 overflow: "auto", 554 boxShadow: "0 20px 60px rgba(0, 0, 0, 0.8)", 555 border: "1px solid var(--atproto-color-border)", 556 }, 557 modalHeader: { 558 display: "flex", 559 justifyContent: "space-between", 560 alignItems: "center", 561 padding: "24px 24px 16px 24px", 562 borderBottom: "1px solid var(--atproto-color-border)", 563 }, 564 modalTitle: { 565 margin: 0, 566 fontSize: 20, 567 fontWeight: 700, 568 color: "var(--atproto-color-text)", 569 }, 570 closeButton: { 571 background: "transparent", 572 border: "none", 573 color: "var(--atproto-color-text-secondary)", 574 fontSize: 32, 575 cursor: "pointer", 576 padding: 0, 577 width: 32, 578 height: 32, 579 display: "flex", 580 alignItems: "center", 581 justifyContent: "center", 582 borderRadius: "50%", 583 transition: "all 0.2s ease", 584 lineHeight: 1, 585 }, 586 platformList: { 587 padding: "16px", 588 display: "flex", 589 flexDirection: "column", 590 gap: "8px", 591 }, 592 platformItem: { 593 display: "flex", 594 alignItems: "center", 595 gap: "16px", 596 padding: "16px", 597 background: "var(--atproto-color-bg-hover)", 598 borderRadius: 12, 599 textDecoration: "none", 600 color: "var(--atproto-color-text)", 601 transition: "all 0.2s ease", 602 cursor: "pointer", 603 border: "1px solid var(--atproto-color-border)", 604 }, 605 platformIcon: { 606 fontSize: 24, 607 width: 32, 608 height: 32, 609 display: "flex", 610 alignItems: "center", 611 justifyContent: "center", 612 }, 613 platformName: { 614 flex: 1, 615 fontSize: 16, 616 fontWeight: 600, 617 }, 618 platformArrow: { 619 opacity: 0.5, 620 transition: "opacity 0.2s ease", 621 }, 622 notListeningContainer: { 623 fontFamily: "system-ui, -apple-system, sans-serif", 624 display: "flex", 625 flexDirection: "column", 626 alignItems: "center", 627 justifyContent: "center", 628 background: "var(--atproto-color-bg)", 629 borderRadius: 16, 630 padding: "80px 40px", 631 maxWidth: 420, 632 color: "var(--atproto-color-text-secondary)", 633 border: "1px solid var(--atproto-color-border)", 634 textAlign: "center", 635 }, 636 notListeningIcon: { 637 width: 120, 638 height: 120, 639 borderRadius: "50%", 640 background: "var(--atproto-color-bg-elevated)", 641 display: "flex", 642 alignItems: "center", 643 justifyContent: "center", 644 marginBottom: 24, 645 color: "var(--atproto-color-text-muted)", 646 }, 647 notListeningTitle: { 648 fontSize: 18, 649 fontWeight: 600, 650 color: "var(--atproto-color-text)", 651 marginBottom: 8, 652 }, 653 notListeningSubtitle: { 654 fontSize: 14, 655 color: "var(--atproto-color-text-secondary)", 656 }, 657}; 658 659// Add keyframes and hover styles 660if (typeof document !== "undefined") { 661 const styleId = "teal-status-styles"; 662 if (!document.getElementById(styleId)) { 663 const styleElement = document.createElement("style"); 664 styleElement.id = styleId; 665 styleElement.textContent = ` 666 @keyframes spin { 667 0% { transform: rotate(0deg); } 668 100% { transform: rotate(360deg); } 669 } 670 671 button[data-teal-listen-button]:hover:not(:disabled), 672 a[data-teal-listen-button]:hover { 673 background: var(--atproto-color-bg-pressed) !important; 674 border-color: var(--atproto-color-border-hover) !important; 675 transform: translateY(-2px); 676 } 677 678 button[data-teal-listen-button]:disabled { 679 opacity: 0.5; 680 cursor: not-allowed; 681 } 682 683 button[data-teal-close]:hover { 684 background: var(--atproto-color-bg-hover) !important; 685 color: var(--atproto-color-text) !important; 686 } 687 688 a[data-teal-platform]:hover { 689 background: var(--atproto-color-bg-pressed) !important; 690 transform: translateX(4px); 691 } 692 693 a[data-teal-platform]:hover svg { 694 opacity: 1 !important; 695 } 696 `; 697 document.head.appendChild(styleElement); 698 } 699} 700 701export default CurrentlyPlayingRenderer;