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