A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
at main 26 kB view raw
1import React, { useState, useEffect, useRef } 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 /** Label to display (e.g., "CURRENTLY PLAYING", "LAST PLAYED"). Defaults to "CURRENTLY PLAYING". */ 12 label?: string; 13 /** Handle to display in not listening state */ 14 handle?: string; 15} 16 17interface SonglinkPlatform { 18 url: string; 19 entityUniqueId: string; 20 nativeAppUriMobile?: string; 21 nativeAppUriDesktop?: string; 22} 23 24interface SonglinkResponse { 25 linksByPlatform: { 26 [platform: string]: SonglinkPlatform; 27 }; 28 entitiesByUniqueId: { 29 [id: string]: { 30 thumbnailUrl?: string; 31 title?: string; 32 artistName?: string; 33 }; 34 }; 35} 36 37export const CurrentlyPlayingRenderer: React.FC<CurrentlyPlayingRendererProps> = ({ 38 record, 39 error, 40 loading, 41 label = "CURRENTLY PLAYING", 42 handle, 43}) => { 44 const [albumArt, setAlbumArt] = useState<string | undefined>(undefined); 45 const [artworkLoading, setArtworkLoading] = useState(true); 46 const [songlinkData, setSonglinkData] = useState<SonglinkResponse | undefined>(undefined); 47 const [showPlatformModal, setShowPlatformModal] = useState(false); 48 const previousTrackIdentityRef = useRef<string>(""); 49 50 // Auto-refresh interval removed - handled by AtProtoRecord 51 52 useEffect(() => { 53 if (!record) return; 54 55 const { item } = record; 56 const artistName = item.artists[0]?.artistName; 57 const trackName = item.trackName; 58 59 if (!artistName || !trackName) { 60 setArtworkLoading(false); 61 return; 62 } 63 64 // Create a unique identity for this track 65 const trackIdentity = `${trackName}::${artistName}`; 66 67 // Check if the track has actually changed 68 const trackHasChanged = trackIdentity !== previousTrackIdentityRef.current; 69 70 // Update tracked identity 71 previousTrackIdentityRef.current = trackIdentity; 72 73 // Only reset loading state and clear data when track actually changes 74 // This prevents the loading flicker when auto-refreshing the same track 75 if (trackHasChanged) { 76 console.log(`[teal.fm] 🎵 Track changed: "${trackName}" by ${artistName}`); 77 setArtworkLoading(true); 78 setAlbumArt(undefined); 79 setSonglinkData(undefined); 80 } else { 81 console.log(`[teal.fm] 🔄 Auto-refresh: same track still playing ("${trackName}" by ${artistName})`); 82 } 83 84 let cancelled = false; 85 86 const fetchMusicData = async () => { 87 try { 88 // Step 1: Check if we have an ISRC - Songlink supports this directly 89 if (item.isrc) { 90 console.log(`[teal.fm] Attempting ISRC lookup for ${trackName} by ${artistName}`, { isrc: item.isrc }); 91 const response = await fetch( 92 `https://api.song.link/v1-alpha.1/links?platform=isrc&type=song&id=${encodeURIComponent(item.isrc)}&songIfSingle=true` 93 ); 94 if (cancelled) return; 95 if (response.ok) { 96 const data = await response.json(); 97 setSonglinkData(data); 98 99 // Extract album art from Songlink data 100 const entityId = data.entityUniqueId; 101 const entity = data.entitiesByUniqueId?.[entityId]; 102 103 // Debug: Log the entity structure to see what fields are available 104 console.log(`[teal.fm] ISRC entity data:`, { entityId, entity }); 105 106 if (entity?.thumbnailUrl) { 107 console.log(`[teal.fm] ✓ Found album art via ISRC lookup`); 108 setAlbumArt(entity.thumbnailUrl); 109 } else { 110 console.warn(`[teal.fm] ISRC lookup succeeded but no thumbnail found`, { 111 entityId, 112 entityKeys: entity ? Object.keys(entity) : 'no entity', 113 entity 114 }); 115 } 116 setArtworkLoading(false); 117 return; 118 } else { 119 console.warn(`[teal.fm] ISRC lookup failed with status ${response.status}`); 120 } 121 } 122 123 // Step 2: Search iTunes Search API to find the track (single request for both artwork and links) 124 console.log(`[teal.fm] Attempting iTunes search for: "${trackName}" by "${artistName}"`); 125 const iTunesSearchUrl = `https://itunes.apple.com/search?term=${encodeURIComponent( 126 `${trackName} ${artistName}` 127 )}&media=music&entity=song&limit=1`; 128 129 const iTunesResponse = await fetch(iTunesSearchUrl); 130 131 if (cancelled) return; 132 133 if (iTunesResponse.ok) { 134 const iTunesData = await iTunesResponse.json(); 135 136 if (iTunesData.results && iTunesData.results.length > 0) { 137 const match = iTunesData.results[0]; 138 const iTunesId = match.trackId; 139 140 // Set album artwork immediately (600x600 for high quality) 141 const artworkUrl = match.artworkUrl100?.replace('100x100', '600x600') || match.artworkUrl100; 142 if (artworkUrl) { 143 console.log(`[teal.fm] ✓ Found album art via iTunes search`, { url: artworkUrl }); 144 setAlbumArt(artworkUrl); 145 } else { 146 console.warn(`[teal.fm] iTunes match found but no artwork URL`); 147 } 148 setArtworkLoading(false); 149 150 // Step 3: Use iTunes ID with Songlink to get all platform links 151 console.log(`[teal.fm] Fetching platform links via Songlink (iTunes ID: ${iTunesId})`); 152 const songlinkResponse = await fetch( 153 `https://api.song.link/v1-alpha.1/links?platform=itunes&type=song&id=${iTunesId}&songIfSingle=true` 154 ); 155 156 if (cancelled) return; 157 158 if (songlinkResponse.ok) { 159 const songlinkData = await songlinkResponse.json(); 160 console.log(`[teal.fm] ✓ Got platform links from Songlink`); 161 setSonglinkData(songlinkData); 162 return; 163 } else { 164 console.warn(`[teal.fm] Songlink request failed with status ${songlinkResponse.status}`); 165 } 166 } else { 167 console.warn(`[teal.fm] No iTunes results found for "${trackName}" by "${artistName}"`); 168 setArtworkLoading(false); 169 } 170 } else { 171 console.warn(`[teal.fm] iTunes search failed with status ${iTunesResponse.status}`); 172 } 173 174 // Step 4: Fallback - if originUrl is from a supported platform, try it directly 175 if (item.originUrl && ( 176 item.originUrl.includes('spotify.com') || 177 item.originUrl.includes('apple.com') || 178 item.originUrl.includes('youtube.com') || 179 item.originUrl.includes('tidal.com') 180 )) { 181 console.log(`[teal.fm] Attempting Songlink lookup via originUrl`, { url: item.originUrl }); 182 const songlinkResponse = await fetch( 183 `https://api.song.link/v1-alpha.1/links?url=${encodeURIComponent(item.originUrl)}&songIfSingle=true` 184 ); 185 186 if (cancelled) return; 187 188 if (songlinkResponse.ok) { 189 const data = await songlinkResponse.json(); 190 console.log(`[teal.fm] ✓ Got data from Songlink via originUrl`); 191 setSonglinkData(data); 192 193 // Try to get artwork from Songlink if we don't have it yet 194 if (!albumArt) { 195 const entityId = data.entityUniqueId; 196 const entity = data.entitiesByUniqueId?.[entityId]; 197 198 // Debug: Log the entity structure to see what fields are available 199 console.log(`[teal.fm] Songlink originUrl entity data:`, { entityId, entity }); 200 201 if (entity?.thumbnailUrl) { 202 console.log(`[teal.fm] ✓ Found album art via Songlink originUrl lookup`); 203 setAlbumArt(entity.thumbnailUrl); 204 } else { 205 console.warn(`[teal.fm] Songlink lookup succeeded but no thumbnail found`, { 206 entityId, 207 entityKeys: entity ? Object.keys(entity) : 'no entity', 208 entity 209 }); 210 } 211 } 212 } else { 213 console.warn(`[teal.fm] Songlink originUrl lookup failed with status ${songlinkResponse.status}`); 214 } 215 } 216 217 if (!albumArt) { 218 console.warn(`[teal.fm] ✗ All album art fetch methods failed for "${trackName}" by "${artistName}"`); 219 } 220 221 setArtworkLoading(false); 222 } catch (err) { 223 console.error(`[teal.fm] ✗ Error fetching music data for "${trackName}" by "${artistName}":`, err); 224 setArtworkLoading(false); 225 } 226 }; 227 228 fetchMusicData(); 229 230 return () => { 231 cancelled = true; 232 }; 233 }, [record]); // Runs on record change 234 235 if (error) 236 return ( 237 <div role="alert" style={{ padding: 8, color: "var(--atproto-color-error)" }}> 238 Failed to load status. 239 </div> 240 ); 241 if (loading && !record) 242 return ( 243 <div role="status" aria-live="polite" style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}> 244 Loading 245 </div> 246 ); 247 248 const { item } = record; 249 250 // Check if user is not listening to anything 251 const isNotListening = !item.trackName || item.artists.length === 0; 252 253 // Show "not listening" state 254 if (isNotListening) { 255 const displayHandle = handle || "User"; 256 return ( 257 <div style={styles.notListeningContainer}> 258 <div style={styles.notListeningIcon}> 259 <svg 260 width="80" 261 height="80" 262 viewBox="0 0 24 24" 263 fill="none" 264 stroke="currentColor" 265 strokeWidth="1.5" 266 strokeLinecap="round" 267 strokeLinejoin="round" 268 > 269 <path d="M9 18V5l12-2v13" /> 270 <circle cx="6" cy="18" r="3" /> 271 <circle cx="18" cy="16" r="3" /> 272 </svg> 273 </div> 274 <div style={styles.notListeningTitle}> 275 {displayHandle} isn't listening to anything 276 </div> 277 <div style={styles.notListeningSubtitle}>Check back soon</div> 278 </div> 279 ); 280 } 281 282 const artistNames = item.artists.map((a) => a.artistName).join(", "); 283 284 const platformConfig: Record<string, { name: string; svg: string; color: string }> = { 285 spotify: { 286 name: "Spotify", 287 svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="#1ed760" d="M248 8C111.1 8 0 119.1 0 256s111.1 248 248 248 248-111.1 248-248S384.9 8 248 8Z"/><path d="M406.6 231.1c-5.2 0-8.4-1.3-12.9-3.9-71.2-42.5-198.5-52.7-280.9-29.7-3.6 1-8.1 2.6-12.9 2.6-13.2 0-23.3-10.3-23.3-23.6 0-13.6 8.4-21.3 17.4-23.9 35.2-10.3 74.6-15.2 117.5-15.2 73 0 149.5 15.2 205.4 47.8 7.8 4.5 12.9 10.7 12.9 22.6 0 13.6-11 23.3-23.2 23.3zm-31 76.2c-5.2 0-8.7-2.3-12.3-4.2-62.5-37-155.7-51.9-238.6-29.4-4.8 1.3-7.4 2.6-11.9 2.6-10.7 0-19.4-8.7-19.4-19.4s5.2-17.8 15.5-20.7c27.8-7.8 56.2-13.6 97.8-13.6 64.9 0 127.6 16.1 177 45.5 8.1 4.8 11.3 11 11.3 19.7-.1 10.8-8.5 19.5-19.4 19.5zm-26.9 65.6c-4.2 0-6.8-1.3-10.7-3.6-62.4-37.6-135-39.2-206.7-24.5-3.9 1-9 2.6-11.9 2.6-9.7 0-15.8-7.7-15.8-15.8 0-10.3 6.1-15.2 13.6-16.8 81.9-18.1 165.6-16.5 237 26.2 6.1 3.9 9.7 7.4 9.7 16.5s-7.1 15.4-15.2 15.4z"/></svg>', 288 color: "#1DB954" 289 }, 290 appleMusic: { 291 name: "Apple Music", 292 svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 361 361"><defs><linearGradient id="apple-grad" x1="180" y1="358.6" x2="180" y2="7.76" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#FA233B"/><stop offset="1" style="stop-color:#FB5C74"/></linearGradient></defs><path fill="url(#apple-grad)" d="M360 112.61V247.39c0 4.3 0 8.6-.02 12.9-.02 3.62-.06 7.24-.16 10.86-.21 7.89-.68 15.84-2.08 23.64-1.42 7.92-3.75 15.29-7.41 22.49-3.6 7.07-8.3 13.53-13.91 19.14-5.61 5.61-12.08 10.31-19.15 13.91-7.19 3.66-14.56 5.98-22.47 7.41-7.8 1.4-15.76 1.87-23.65 2.08-3.62.1-7.24.14-10.86.16-4.3.03-8.6.02-12.9.02H112.61c-4.3 0-8.6 0-12.9-.02-3.62-.02-7.24-.06-10.86-.16-7.89-.21-15.85-.68-23.65-2.08-7.92-1.42-15.28-3.75-22.47-7.41-7.07-3.6-13.54-8.3-19.15-13.91-5.61-5.61-10.31-12.07-13.91-19.14-3.66-7.2-5.99-14.57-7.41-22.49-1.4-7.8-1.87-15.76-2.08-23.64-.1-3.62-.14-7.24-.16-10.86C0 255.99 0 251.69 0 247.39V112.61c0-4.3 0-8.6.02-12.9.02-3.62.06-7.24.16-10.86.21-7.89.68-15.84 2.08-23.64 1.42-7.92 3.75-15.29 7.41-22.49 3.6-7.07 8.3-13.53 13.91-19.14 5.61-5.61 12.08-10.31 19.15-13.91 7.19-3.66 14.56-5.98 22.47-7.41 7.8-1.4 15.76-1.87 23.65-2.08 3.62-.1 7.24-.14 10.86-.16C104.01 0 108.31 0 112.61 0h134.77c4.3 0 8.6 0 12.9.02 3.62.02 7.24.06 10.86.16 7.89.21 15.85.68 23.65 2.08 7.92 1.42 15.28 3.75 22.47 7.41 7.07 3.6 13.54 8.3 19.15 13.91 5.61 5.61 10.31 12.07 13.91 19.14 3.66 7.2 5.99 14.57 7.41 22.49 1.4 7.8 1.87 15.76 2.08 23.64.1 3.62.14 7.24.16 10.86.03 4.3.02 8.6.02 12.9z"/><path fill="#FFF" d="M254.5 55c-.87.08-8.6 1.45-9.53 1.64l-107 21.59-.04.01c-2.79.59-4.98 1.58-6.67 3-2.04 1.71-3.17 4.13-3.6 6.95-.09.6-.24 1.82-.24 3.62v133.92c0 3.13-.25 6.17-2.37 8.76-2.12 2.59-4.74 3.37-7.81 3.99-2.33.47-4.66.94-6.99 1.41-8.84 1.78-14.59 2.99-19.8 5.01-4.98 1.93-8.71 4.39-11.68 7.51-5.89 6.17-8.28 14.54-7.46 22.38.7 6.69 3.71 13.09 8.88 17.82 3.49 3.2 7.85 5.63 12.99 6.66 5.33 1.07 11.01.7 19.31-.98 4.42-.89 8.56-2.28 12.5-4.61 3.9-2.3 7.24-5.37 9.85-9.11 2.62-3.75 4.31-7.92 5.24-12.35.96-4.57 1.19-8.7 1.19-13.26V128.82c0-6.22 1.76-7.86 6.78-9.08l93.09-18.75c5.79-1.11 8.52.54 8.52 6.61v79.29c0 3.14-.03 6.32-2.17 8.92-2.12 2.59-4.74 3.37-7.81 3.99-2.33.47-4.66.94-6.99 1.41-8.84 1.78-14.59 2.99-19.8 5.01-4.98 1.93-8.71 4.39-11.68 7.51-5.89 6.17-8.49 14.54-7.67 22.38.7 6.69 3.92 13.09 9.09 17.82 3.49 3.2 7.85 5.56 12.99 6.6 5.33 1.07 11.01.69 19.31-.98 4.42-.89 8.56-2.22 12.5-4.55 3.9-2.3 7.24-5.37 9.85-9.11 2.62-3.75 4.31-7.92 5.24-12.35.96-4.57 1-8.7 1-13.26V64.46c0-6.16-3.25-9.96-9.04-9.46z"/></svg>', 293 color: "#FA243C" 294 }, 295 youtube: { 296 name: "YouTube", 297 svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><g transform="scale(.75)"><path fill="red" d="M199.917 105.63s-84.292 0-105.448 5.497c-11.328 3.165-20.655 12.493-23.82 23.987-5.498 21.156-5.498 64.969-5.498 64.969s0 43.979 5.497 64.802c3.165 11.494 12.326 20.655 23.82 23.82 21.323 5.664 105.448 5.664 105.448 5.664s84.459 0 105.615-5.497c11.494-3.165 20.655-12.16 23.654-23.82 5.664-20.99 5.664-64.803 5.664-64.803s.166-43.98-5.664-65.135c-2.999-11.494-12.16-20.655-23.654-23.654-21.156-5.83-105.615-5.83-105.615-5.83zm-26.82 53.974 70.133 40.479-70.133 40.312v-80.79z"/><path fill="#fff" d="m173.097 159.604 70.133 40.479-70.133 40.312v-80.79z"/></g></svg>', 298 color: "#FF0000" 299 }, 300 youtubeMusic: { 301 name: "YouTube Music", 302 svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 176 176"><circle fill="#FF0000" cx="88" cy="88" r="88"/><path fill="#FFF" d="M88 46c23.1 0 42 18.8 42 42s-18.8 42-42 42-42-18.8-42-42 18.8-42 42-42m0-4c-25.4 0-46 20.6-46 46s20.6 46 46 46 46-20.6 46-46-20.6-46-46-46z"/><path fill="#FFF" d="m72 111 39-24-39-22z"/></svg>', 303 color: "#FF0000" 304 }, 305 tidal: { 306 name: "Tidal", 307 svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 0c141.385 0 256 114.615 256 256S397.385 512 256 512 0 397.385 0 256 114.615 0 256 0zm50.384 219.459-50.372 50.383 50.379 50.391-50.382 50.393-50.395-50.393 50.393-50.389-50.393-50.39 50.395-50.372 50.38 50.369 50.389-50.375 50.382 50.382-50.382 50.392-50.394-50.391zm-100.767-.001-50.392 50.392-50.385-50.392 50.385-50.382 50.392 50.382z"/></svg>', 308 color: "#000000" 309 }, 310 bandcamp: { 311 name: "Bandcamp", 312 svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#1DA0C3" d="M0 156v200h172l84-200z"/></svg>', 313 color: "#1DA0C3" 314 }, 315 }; 316 317 const availablePlatforms = songlinkData 318 ? Object.keys(platformConfig).filter((platform) => 319 songlinkData.linksByPlatform[platform] 320 ) 321 : []; 322 323 return ( 324 <> 325 <div style={styles.container}> 326 {/* Album Artwork */} 327 <div style={styles.artworkContainer}> 328 {artworkLoading ? ( 329 <div style={styles.artworkPlaceholder}> 330 <div style={styles.loadingSpinner} /> 331 </div> 332 ) : albumArt ? ( 333 <img 334 src={albumArt} 335 alt={`${item.releaseName || "Album"} cover`} 336 style={styles.artwork} 337 onError={(e) => { 338 console.error("Failed to load album art:", { 339 url: albumArt, 340 track: item.trackName, 341 artist: item.artists[0]?.artistName, 342 error: "Image load error" 343 }); 344 e.currentTarget.style.display = "none"; 345 }} 346 /> 347 ) : ( 348 <div style={styles.artworkPlaceholder}> 349 <svg 350 width="64" 351 height="64" 352 viewBox="0 0 24 24" 353 fill="none" 354 stroke="currentColor" 355 strokeWidth="1.5" 356 > 357 <circle cx="12" cy="12" r="10" /> 358 <circle cx="12" cy="12" r="3" /> 359 <path d="M12 2v3M12 19v3M2 12h3M19 12h3" /> 360 </svg> 361 </div> 362 )} 363 </div> 364 365 {/* Content */} 366 <div style={styles.content}> 367 <div style={styles.label}>{label}</div> 368 <h2 style={styles.trackName}>{item.trackName}</h2> 369 <div style={styles.artistName}>{artistNames}</div> 370 {item.releaseName && ( 371 <div style={styles.releaseName}>from {item.releaseName}</div> 372 )} 373 374 {/* Listen Button */} 375 {availablePlatforms.length > 0 ? ( 376 <button 377 onClick={() => setShowPlatformModal(true)} 378 style={styles.listenButton} 379 data-teal-listen-button="true" 380 > 381 <span>Listen with your Streaming Client</span> 382 <svg 383 width="16" 384 height="16" 385 viewBox="0 0 24 24" 386 fill="none" 387 stroke="currentColor" 388 strokeWidth="2" 389 strokeLinecap="round" 390 strokeLinejoin="round" 391 > 392 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> 393 <polyline points="15 3 21 3 21 9" /> 394 <line x1="10" y1="14" x2="21" y2="3" /> 395 </svg> 396 </button> 397 ) : item.originUrl ? ( 398 <a 399 href={item.originUrl} 400 target="_blank" 401 rel="noopener noreferrer" 402 style={styles.listenButton} 403 data-teal-listen-button="true" 404 > 405 <span>Listen on Last.fm</span> 406 <svg 407 width="16" 408 height="16" 409 viewBox="0 0 24 24" 410 fill="none" 411 stroke="currentColor" 412 strokeWidth="2" 413 strokeLinecap="round" 414 strokeLinejoin="round" 415 > 416 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> 417 <polyline points="15 3 21 3 21 9" /> 418 <line x1="10" y1="14" x2="21" y2="3" /> 419 </svg> 420 </a> 421 ) : null} 422 </div> 423 </div> 424 425 {/* Platform Selection Modal */} 426 {showPlatformModal && songlinkData && ( 427 <div style={styles.modalOverlay} onClick={() => setShowPlatformModal(false)}> 428 <div 429 role="dialog" 430 aria-modal="true" 431 aria-labelledby="platform-modal-title" 432 style={styles.modalContent} 433 onClick={(e) => e.stopPropagation()} 434 > 435 <div style={styles.modalHeader}> 436 <h3 id="platform-modal-title" style={styles.modalTitle}>Choose your streaming service</h3> 437 <button 438 style={styles.closeButton} 439 onClick={() => setShowPlatformModal(false)} 440 data-teal-close="true" 441 > 442 × 443 </button> 444 </div> 445 <div style={styles.platformList}> 446 {availablePlatforms.map((platform) => { 447 const config = platformConfig[platform]; 448 const link = songlinkData.linksByPlatform[platform]; 449 return ( 450 <a 451 key={platform} 452 href={link.url} 453 target="_blank" 454 rel="noopener noreferrer" 455 style={{ 456 ...styles.platformItem, 457 borderLeft: `4px solid ${config.color}`, 458 }} 459 onClick={() => setShowPlatformModal(false)} 460 data-teal-platform="true" 461 > 462 <span 463 style={styles.platformIcon} 464 dangerouslySetInnerHTML={{ __html: config.svg }} 465 /> 466 <span style={styles.platformName}>{config.name}</span> 467 <svg 468 width="20" 469 height="20" 470 viewBox="0 0 24 24" 471 fill="none" 472 stroke="currentColor" 473 strokeWidth="2" 474 style={styles.platformArrow} 475 > 476 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> 477 <polyline points="15 3 21 3 21 9" /> 478 <line x1="10" y1="14" x2="21" y2="3" /> 479 </svg> 480 </a> 481 ); 482 })} 483 </div> 484 </div> 485 </div> 486 )} 487 </> 488 ); 489}; 490 491const styles: Record<string, React.CSSProperties> = { 492 container: { 493 fontFamily: "system-ui, -apple-system, sans-serif", 494 display: "flex", 495 flexDirection: "column", 496 background: "var(--atproto-color-bg)", 497 borderRadius: 16, 498 overflow: "hidden", 499 maxWidth: 420, 500 color: "var(--atproto-color-text)", 501 boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)", 502 border: "1px solid var(--atproto-color-border)", 503 }, 504 artworkContainer: { 505 width: "100%", 506 aspectRatio: "1 / 1", 507 position: "relative", 508 overflow: "hidden", 509 }, 510 artwork: { 511 width: "100%", 512 height: "100%", 513 objectFit: "cover", 514 display: "block", 515 }, 516 artworkPlaceholder: { 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.5)", 524 }, 525 loadingSpinner: { 526 width: 40, 527 height: 40, 528 border: "3px solid var(--atproto-color-border)", 529 borderTop: "3px solid var(--atproto-color-primary)", 530 borderRadius: "50%", 531 animation: "spin 1s linear infinite", 532 }, 533 content: { 534 padding: "24px", 535 display: "flex", 536 flexDirection: "column", 537 gap: "8px", 538 }, 539 label: { 540 fontSize: 11, 541 fontWeight: 600, 542 letterSpacing: "0.1em", 543 textTransform: "uppercase", 544 color: "var(--atproto-color-text-secondary)", 545 marginBottom: "4px", 546 }, 547 trackName: { 548 fontSize: 28, 549 fontWeight: 700, 550 margin: 0, 551 lineHeight: 1.2, 552 color: "var(--atproto-color-text)", 553 }, 554 artistName: { 555 fontSize: 16, 556 color: "var(--atproto-color-text-secondary)", 557 marginTop: "4px", 558 }, 559 releaseName: { 560 fontSize: 14, 561 color: "var(--atproto-color-text-secondary)", 562 marginTop: "2px", 563 }, 564 listenButton: { 565 display: "inline-flex", 566 alignItems: "center", 567 gap: "8px", 568 marginTop: "16px", 569 padding: "12px 20px", 570 background: "var(--atproto-color-bg-elevated)", 571 border: "1px solid var(--atproto-color-border)", 572 borderRadius: 24, 573 color: "var(--atproto-color-text)", 574 fontSize: 14, 575 fontWeight: 600, 576 textDecoration: "none", 577 cursor: "pointer", 578 transition: "all 0.2s ease", 579 alignSelf: "flex-start", 580 }, 581 modalOverlay: { 582 position: "fixed", 583 top: 0, 584 left: 0, 585 right: 0, 586 bottom: 0, 587 backgroundColor: "rgba(0, 0, 0, 0.85)", 588 display: "flex", 589 alignItems: "center", 590 justifyContent: "center", 591 zIndex: 9999, 592 backdropFilter: "blur(4px)", 593 }, 594 modalContent: { 595 background: "var(--atproto-color-bg)", 596 borderRadius: 16, 597 padding: 0, 598 maxWidth: 450, 599 width: "90%", 600 maxHeight: "80vh", 601 overflow: "auto", 602 boxShadow: "0 20px 60px rgba(0, 0, 0, 0.8)", 603 border: "1px solid var(--atproto-color-border)", 604 }, 605 modalHeader: { 606 display: "flex", 607 justifyContent: "space-between", 608 alignItems: "center", 609 padding: "24px 24px 16px 24px", 610 borderBottom: "1px solid var(--atproto-color-border)", 611 }, 612 modalTitle: { 613 margin: 0, 614 fontSize: 20, 615 fontWeight: 700, 616 color: "var(--atproto-color-text)", 617 }, 618 closeButton: { 619 background: "transparent", 620 border: "none", 621 color: "var(--atproto-color-text-secondary)", 622 fontSize: 32, 623 cursor: "pointer", 624 padding: 0, 625 width: 32, 626 height: 32, 627 display: "flex", 628 alignItems: "center", 629 justifyContent: "center", 630 borderRadius: "50%", 631 transition: "all 0.2s ease", 632 lineHeight: 1, 633 }, 634 platformList: { 635 padding: "16px", 636 display: "flex", 637 flexDirection: "column", 638 gap: "8px", 639 }, 640 platformItem: { 641 display: "flex", 642 alignItems: "center", 643 gap: "16px", 644 padding: "16px", 645 background: "var(--atproto-color-bg-hover)", 646 borderRadius: 12, 647 textDecoration: "none", 648 color: "var(--atproto-color-text)", 649 transition: "all 0.2s ease", 650 cursor: "pointer", 651 border: "1px solid var(--atproto-color-border)", 652 }, 653 platformIcon: { 654 fontSize: 24, 655 width: 32, 656 height: 32, 657 display: "flex", 658 alignItems: "center", 659 justifyContent: "center", 660 }, 661 platformName: { 662 flex: 1, 663 fontSize: 16, 664 fontWeight: 600, 665 }, 666 platformArrow: { 667 opacity: 0.5, 668 transition: "opacity 0.2s ease", 669 }, 670 notListeningContainer: { 671 fontFamily: "system-ui, -apple-system, sans-serif", 672 display: "flex", 673 flexDirection: "column", 674 alignItems: "center", 675 justifyContent: "center", 676 background: "var(--atproto-color-bg)", 677 borderRadius: 16, 678 padding: "80px 40px", 679 maxWidth: 420, 680 color: "var(--atproto-color-text-secondary)", 681 border: "1px solid var(--atproto-color-border)", 682 textAlign: "center", 683 }, 684 notListeningIcon: { 685 width: 120, 686 height: 120, 687 borderRadius: "50%", 688 background: "var(--atproto-color-bg-elevated)", 689 display: "flex", 690 alignItems: "center", 691 justifyContent: "center", 692 marginBottom: 24, 693 color: "var(--atproto-color-text-muted)", 694 }, 695 notListeningTitle: { 696 fontSize: 18, 697 fontWeight: 600, 698 color: "var(--atproto-color-text)", 699 marginBottom: 8, 700 }, 701 notListeningSubtitle: { 702 fontSize: 14, 703 color: "var(--atproto-color-text-secondary)", 704 }, 705}; 706 707// Add keyframes and hover styles 708if (typeof document !== "undefined") { 709 const styleId = "teal-status-styles"; 710 if (!document.getElementById(styleId)) { 711 const styleElement = document.createElement("style"); 712 styleElement.id = styleId; 713 styleElement.textContent = ` 714 @keyframes spin { 715 0% { transform: rotate(0deg); } 716 100% { transform: rotate(360deg); } 717 } 718 719 button[data-teal-listen-button]:hover:not(:disabled), 720 a[data-teal-listen-button]:hover { 721 background: var(--atproto-color-bg-pressed) !important; 722 border-color: var(--atproto-color-border-hover) !important; 723 transform: translateY(-2px); 724 } 725 726 button[data-teal-listen-button]:disabled { 727 opacity: 0.5; 728 cursor: not-allowed; 729 } 730 731 button[data-teal-close]:hover { 732 background: var(--atproto-color-bg-hover) !important; 733 color: var(--atproto-color-text) !important; 734 } 735 736 a[data-teal-platform]:hover { 737 background: var(--atproto-color-bg-pressed) !important; 738 transform: translateX(4px); 739 } 740 741 a[data-teal-platform]:hover svg { 742 opacity: 1 !important; 743 } 744 `; 745 document.head.appendChild(styleElement); 746 } 747} 748 749export default CurrentlyPlayingRenderer;