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

fix ,music components

+26 -8
lib/components/CurrentlyPlaying.tsx
··· 22 22 loadingIndicator?: React.ReactNode; 23 23 /** Preferred color scheme for theming. */ 24 24 colorScheme?: "light" | "dark" | "system"; 25 - /** Auto-refresh music data and album art every 15 seconds. Defaults to true. */ 25 + /** Auto-refresh music data and album art. When true, refreshes every 15 seconds. Defaults to true. */ 26 26 autoRefresh?: boolean; 27 + /** Refresh interval in milliseconds. Defaults to 15000 (15 seconds). Only used when autoRefresh is true. */ 28 + refreshInterval?: number; 27 29 } 28 30 29 31 /** ··· 42 44 did: string; 43 45 /** Record key for the status. */ 44 46 rkey: string; 45 - /** Auto-refresh music data and album art every 15 seconds. */ 46 - autoRefresh?: boolean; 47 47 /** Label to display. */ 48 48 label?: string; 49 - /** Refresh interval in milliseconds. */ 50 - refreshInterval?: number; 51 49 /** Handle to display in not listening state */ 52 50 handle?: string; 53 51 }; ··· 56 54 export const CURRENTLY_PLAYING_COLLECTION = "fm.teal.alpha.actor.status"; 57 55 58 56 /** 57 + * Compares two teal.fm status records to determine if the track has changed. 58 + * Used to prevent unnecessary re-renders during auto-refresh when the same track is still playing. 59 + */ 60 + const compareTealRecords = ( 61 + prev: TealActorStatusRecord | undefined, 62 + next: TealActorStatusRecord | undefined 63 + ): boolean => { 64 + if (!prev || !next) return prev === next; 65 + 66 + const prevTrack = prev.item.trackName; 67 + const nextTrack = next.item.trackName; 68 + const prevArtist = prev.item.artists[0]?.artistName; 69 + const nextArtist = next.item.artists[0]?.artistName; 70 + 71 + return prevTrack === nextTrack && prevArtist === nextArtist; 72 + }; 73 + 74 + /** 59 75 * Displays the currently playing track from teal.fm with auto-refresh. 60 76 * 61 77 * @param did - DID whose currently playing status should be fetched. ··· 64 80 * @param fallback - Node rendered before the first load begins. 65 81 * @param loadingIndicator - Node rendered while the status is loading. 66 82 * @param colorScheme - Preferred color scheme for theming the renderer. 67 - * @param autoRefresh - When true (default), refreshes album art and streaming platform links every 15 seconds. 83 + * @param autoRefresh - When true (default), refreshes the record every 15 seconds (or custom interval). 84 + * @param refreshInterval - Custom refresh interval in milliseconds. Defaults to 15000 (15 seconds). 68 85 * @returns A JSX subtree representing the currently playing track with loading states handled. 69 86 */ 70 87 export const CurrentlyPlaying: React.FC<CurrentlyPlayingProps> = React.memo(({ ··· 76 93 loadingIndicator, 77 94 colorScheme, 78 95 autoRefresh = true, 96 + refreshInterval = 15000, 79 97 }) => { 80 98 // Resolve handle from DID 81 99 const { handle } = useDidResolution(did); ··· 92 110 colorScheme={colorScheme} 93 111 did={did} 94 112 rkey={rkey} 95 - autoRefresh={autoRefresh} 96 113 label="CURRENTLY PLAYING" 97 - refreshInterval={15000} 98 114 handle={handle} 99 115 /> 100 116 ); ··· 118 134 renderer={Wrapped} 119 135 fallback={fallback} 120 136 loadingIndicator={loadingIndicator} 137 + refreshInterval={autoRefresh ? refreshInterval : undefined} 138 + compareRecords={compareTealRecords} 121 139 /> 122 140 ); 123 141 });
+18 -9
lib/components/LastPlayed.tsx
··· 2 2 import { useLatestRecord } from "../hooks/useLatestRecord"; 3 3 import { useDidResolution } from "../hooks/useDidResolution"; 4 4 import { CurrentlyPlayingRenderer } from "../renderers/CurrentlyPlayingRenderer"; 5 - import type { TealFeedPlayRecord } from "../types/teal"; 5 + import type { TealFeedPlayRecord, TealActorStatusRecord } from "../types/teal"; 6 6 7 7 /** 8 8 * Props for rendering the last played track from teal.fm feed. ··· 29 29 */ 30 30 export type LastPlayedRendererInjectedProps = { 31 31 /** Loaded teal.fm feed play record value. */ 32 - record: TealFeedPlayRecord; 32 + record: TealActorStatusRecord; 33 33 /** Indicates whether the record is currently loading. */ 34 34 loading: boolean; 35 35 /** Fetch error, if any. */ ··· 40 40 did: string; 41 41 /** Record key for the play record. */ 42 42 rkey: string; 43 - /** Auto-refresh music data and album art. */ 44 - autoRefresh?: boolean; 45 - /** Refresh interval in milliseconds. */ 46 - refreshInterval?: number; 47 43 /** Handle to display in not listening state */ 48 44 handle?: string; 49 45 }; ··· 75 71 // Resolve handle from DID 76 72 const { handle } = useDidResolution(did); 77 73 74 + // Auto-refresh key for refetching teal.fm record 75 + const [refreshKey, setRefreshKey] = React.useState(0); 76 + 77 + // Auto-refresh interval 78 + React.useEffect(() => { 79 + if (!autoRefresh) return; 80 + 81 + const interval = setInterval(() => { 82 + setRefreshKey((prev) => prev + 1); 83 + }, refreshInterval); 84 + 85 + return () => clearInterval(interval); 86 + }, [autoRefresh, refreshInterval]); 87 + 78 88 const { record, rkey, loading, error, empty } = useLatestRecord<TealFeedPlayRecord>( 79 89 did, 80 - LAST_PLAYED_COLLECTION 90 + LAST_PLAYED_COLLECTION, 91 + refreshKey, 81 92 ); 82 93 83 94 // Normalize TealFeedPlayRecord to match TealActorStatusRecord structure ··· 145 156 colorScheme={colorScheme} 146 157 did={did} 147 158 rkey={rkey || "unknown"} 148 - autoRefresh={autoRefresh} 149 159 label="LAST PLAYED" 150 - refreshInterval={refreshInterval} 151 160 handle={handle} 152 161 /> 153 162 );
+68 -6
lib/core/AtProtoRecord.tsx
··· 1 - import React from "react"; 1 + import React, { useState, useEffect, useRef } from "react"; 2 2 import { useAtProtoRecord } from "../hooks/useAtProtoRecord"; 3 3 4 4 /** ··· 15 15 fallback?: React.ReactNode; 16 16 /** React node shown while the record is being fetched. */ 17 17 loadingIndicator?: React.ReactNode; 18 + /** Auto-refresh interval in milliseconds. When set, the record will be refetched at this interval. */ 19 + refreshInterval?: number; 20 + /** Comparison function to determine if a record has changed. Used to prevent unnecessary re-renders during auto-refresh. */ 21 + compareRecords?: (prev: T | undefined, next: T | undefined) => boolean; 18 22 } 19 23 20 24 /** ··· 61 65 * 62 66 * When no custom renderer is provided, displays the record as formatted JSON. 63 67 * 68 + * **Auto-refresh**: Set `refreshInterval` to automatically refetch the record at the specified interval. 69 + * The component intelligently avoids re-rendering if the record hasn't changed (using `compareRecords`). 70 + * 64 71 * @example 65 72 * ```tsx 66 73 * // Fetch mode - retrieves record from network ··· 81 88 * /> 82 89 * ``` 83 90 * 91 + * @example 92 + * ```tsx 93 + * // Auto-refresh mode - refetches every 15 seconds 94 + * <AtProtoRecord 95 + * did="did:plc:example" 96 + * collection="fm.teal.alpha.actor.status" 97 + * rkey="self" 98 + * refreshInterval={15000} 99 + * compareRecords={(prev, next) => JSON.stringify(prev) === JSON.stringify(next)} 100 + * renderer={MyCustomRenderer} 101 + * /> 102 + * ``` 103 + * 84 104 * @param props - Either fetch props (did/collection/rkey) or prefetch props (record). 85 105 * @returns A rendered AT Protocol record with loading/error states handled. 86 106 */ ··· 89 109 renderer: Renderer, 90 110 fallback = null, 91 111 loadingIndicator = "Loading…", 112 + refreshInterval, 113 + compareRecords, 92 114 } = props; 93 115 const hasProvidedRecord = "record" in props; 94 116 const providedRecord = hasProvidedRecord ? props.record : undefined; 95 117 118 + // Extract fetch props for logging 119 + const fetchDid = hasProvidedRecord ? undefined : (props as any).did; 120 + const fetchCollection = hasProvidedRecord ? undefined : (props as any).collection; 121 + const fetchRkey = hasProvidedRecord ? undefined : (props as any).rkey; 122 + 123 + // State for managing auto-refresh 124 + const [refreshKey, setRefreshKey] = useState(0); 125 + const [stableRecord, setStableRecord] = useState<T | undefined>(providedRecord); 126 + const previousRecordRef = useRef<T | undefined>(providedRecord); 127 + 128 + // Auto-refresh interval 129 + useEffect(() => { 130 + if (!refreshInterval || hasProvidedRecord) return; 131 + 132 + const interval = setInterval(() => { 133 + setRefreshKey((prev) => prev + 1); 134 + }, refreshInterval); 135 + 136 + return () => clearInterval(interval); 137 + }, [refreshInterval, hasProvidedRecord, fetchCollection, fetchDid]); 138 + 96 139 const { 97 140 record: fetchedRecord, 98 141 error, 99 142 loading, 100 143 } = useAtProtoRecord<T>({ 101 - did: hasProvidedRecord ? undefined : props.did, 102 - collection: hasProvidedRecord ? undefined : props.collection, 103 - rkey: hasProvidedRecord ? undefined : props.rkey, 144 + did: fetchDid, 145 + collection: fetchCollection, 146 + rkey: fetchRkey, 147 + bypassCache: !!refreshInterval && refreshKey > 0, // Bypass cache on auto-refresh (but not initial load) 148 + _refreshKey: refreshKey, // Force hook to re-run 104 149 }); 105 150 106 - const record = providedRecord ?? fetchedRecord; 107 - const isLoading = loading && !providedRecord; 151 + // Determine which record to use 152 + const currentRecord = providedRecord ?? fetchedRecord; 153 + 154 + // Handle record changes with optional comparison 155 + useEffect(() => { 156 + if (!currentRecord) return; 157 + 158 + const hasChanged = compareRecords 159 + ? !compareRecords(previousRecordRef.current, currentRecord) 160 + : previousRecordRef.current !== currentRecord; 161 + 162 + if (hasChanged) { 163 + setStableRecord(currentRecord); 164 + previousRecordRef.current = currentRecord; 165 + } 166 + }, [currentRecord, compareRecords]); 167 + 168 + const record = stableRecord; 169 + const isLoading = loading && !providedRecord && !stableRecord; 108 170 109 171 if (error && !record) return <>{fallback}</>; 110 172 if (!record) return <>{isLoading ? loadingIndicator : fallback}</>;
+70
lib/hooks/useAtProtoRecord.ts
··· 15 15 collection?: string; 16 16 /** Record key string uniquely identifying the record within the collection. */ 17 17 rkey?: string; 18 + /** Force bypass cache and refetch from network. Useful for auto-refresh scenarios. */ 19 + bypassCache?: boolean; 20 + /** Internal refresh trigger - changes to this value force a refetch. */ 21 + _refreshKey?: number; 18 22 } 19 23 20 24 /** ··· 42 46 * @param did - DID (or handle before resolution) that owns the record. 43 47 * @param collection - NSID collection from which to fetch the record. 44 48 * @param rkey - Record key identifying the record within the collection. 49 + * @param bypassCache - Force bypass cache and refetch from network. Useful for auto-refresh scenarios. 50 + * @param _refreshKey - Internal parameter used to trigger refetches. 45 51 * @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag. 46 52 */ 47 53 export function useAtProtoRecord<T = unknown>({ 48 54 did: handleOrDid, 49 55 collection, 50 56 rkey, 57 + bypassCache = false, 58 + _refreshKey = 0, 51 59 }: AtProtoRecordKey): AtProtoRecordState<T> { 52 60 const { recordCache } = useAtProto(); 53 61 const isBlueskyCollection = collection?.startsWith("app.bsky."); ··· 133 141 134 142 assignState({ loading: true, error: undefined, record: undefined }); 135 143 144 + // Bypass cache if requested (for auto-refresh scenarios) 145 + if (bypassCache) { 146 + assignState({ loading: true, error: undefined }); 147 + 148 + // Skip cache and fetch directly 149 + const controller = new AbortController(); 150 + 151 + const fetchPromise = (async () => { 152 + try { 153 + const { rpc } = await createAtprotoClient({ 154 + service: endpoint, 155 + }); 156 + const res = await ( 157 + rpc as unknown as { 158 + get: ( 159 + nsid: string, 160 + opts: { 161 + params: { 162 + repo: string; 163 + collection: string; 164 + rkey: string; 165 + }; 166 + }, 167 + ) => Promise<{ ok: boolean; data: { value: T } }>; 168 + } 169 + ).get("com.atproto.repo.getRecord", { 170 + params: { repo: did, collection, rkey }, 171 + }); 172 + if (!res.ok) throw new Error("Failed to load record"); 173 + return (res.data as { value: T }).value; 174 + } catch (err) { 175 + // Provide helpful error for banned/unreachable Bluesky PDSes 176 + if (endpoint.includes('.bsky.network')) { 177 + throw new Error( 178 + `Record unavailable. The Bluesky PDS (${endpoint}) may be unreachable or the account may be banned.` 179 + ); 180 + } 181 + throw err; 182 + } 183 + })(); 184 + 185 + fetchPromise 186 + .then((record) => { 187 + if (!cancelled) { 188 + assignState({ record, loading: false }); 189 + } 190 + }) 191 + .catch((e) => { 192 + if (!cancelled) { 193 + const err = e instanceof Error ? e : new Error(String(e)); 194 + assignState({ error: err, loading: false }); 195 + } 196 + }); 197 + 198 + return () => { 199 + cancelled = true; 200 + controller.abort(); 201 + }; 202 + } 203 + 136 204 // Use recordCache.ensure for deduplication and caching 137 205 const { promise, release } = recordCache.ensure<T>( 138 206 did, ··· 215 283 didError, 216 284 endpointError, 217 285 recordCache, 286 + bypassCache, 287 + _refreshKey, 218 288 ]); 219 289 220 290 // Return Bluesky result for app.bsky.* collections
+26 -7
lib/hooks/useBlueskyAppview.ts
··· 236 236 }: UseBlueskyAppviewOptions): UseBlueskyAppviewResult<T> { 237 237 const { recordCache, blueskyAppviewService, resolver } = useAtProto(); 238 238 const effectiveAppviewService = appviewService ?? blueskyAppviewService; 239 + 240 + // Only use this hook for Bluesky collections (app.bsky.*) 241 + const isBlueskyCollection = collection?.startsWith("app.bsky."); 242 + 239 243 const { 240 244 did, 241 245 error: didError, ··· 261 265 262 266 // Early returns for missing inputs or resolution errors 263 267 if (!handleOrDid || !collection || !rkey) { 268 + if (!cancelled) dispatch({ type: "RESET" }); 269 + return () => { 270 + cancelled = true; 271 + if (releaseRef.current) { 272 + releaseRef.current(); 273 + releaseRef.current = undefined; 274 + } 275 + }; 276 + } 277 + 278 + // Return early if not a Bluesky collection - this hook should not be used for other lexicons 279 + if (!isBlueskyCollection) { 264 280 if (!cancelled) dispatch({ type: "RESET" }); 265 281 return () => { 266 282 cancelled = true; ··· 683 699 }; 684 700 }> { 685 701 const { rpc } = await createAtprotoClient({ service }); 702 + 703 + const params: Record<string, unknown> = { 704 + repo: did, 705 + collection, 706 + limit, 707 + cursor, 708 + reverse: false, 709 + }; 710 + 686 711 return await (rpc as unknown as { 687 712 get: ( 688 713 nsid: string, ··· 695 720 }; 696 721 }>; 697 722 }).get("com.atproto.repo.listRecords", { 698 - params: { 699 - repo: did, 700 - collection, 701 - limit, 702 - cursor, 703 - reverse: false, 704 - }, 723 + params, 705 724 }); 706 725 } 707 726
+5 -2
lib/hooks/useLatestRecord.ts
··· 21 21 22 22 /** 23 23 * Fetches the most recent record from a collection using `listRecords(limit=3)`. 24 - * 24 + * 25 25 * Note: Slingshot does not support listRecords, so this always queries the actor's PDS directly. 26 - * 26 + * 27 27 * Records with invalid timestamps (before 2023, when ATProto was created) are automatically 28 28 * skipped, and additional records are fetched to find a valid one. 29 29 * 30 30 * @param handleOrDid - Handle or DID that owns the collection. 31 31 * @param collection - NSID of the collection to query. 32 + * @param refreshKey - Optional key that when changed, triggers a refetch. Use for auto-refresh scenarios. 32 33 * @returns {LatestRecordState<T>} Object reporting the latest record value, derived rkey, loading status, emptiness, and any error. 33 34 */ 34 35 export function useLatestRecord<T = unknown>( 35 36 handleOrDid: string | undefined, 36 37 collection: string, 38 + refreshKey?: number, 37 39 ): LatestRecordState<T> { 38 40 const { 39 41 did, ··· 157 159 resolvingEndpoint, 158 160 didError, 159 161 endpointError, 162 + refreshKey, 160 163 ]); 161 164 162 165 return state;
+74 -32
lib/renderers/CurrentlyPlayingRenderer.tsx
··· 1 - import React, { useState, useEffect } from "react"; 1 + import React, { useState, useEffect, useRef } from "react"; 2 2 import type { TealActorStatusRecord } from "../types/teal"; 3 3 4 4 export interface CurrentlyPlayingRendererProps { ··· 8 8 did: string; 9 9 rkey: string; 10 10 colorScheme?: "light" | "dark" | "system"; 11 - autoRefresh?: boolean; 12 11 /** Label to display (e.g., "CURRENTLY PLAYING", "LAST PLAYED"). Defaults to "CURRENTLY PLAYING". */ 13 12 label?: string; 14 - /** Refresh interval in milliseconds. Defaults to 15000 (15 seconds). */ 15 - refreshInterval?: number; 16 13 /** Handle to display in not listening state */ 17 14 handle?: string; 18 15 } ··· 41 38 record, 42 39 error, 43 40 loading, 44 - autoRefresh = true, 45 41 label = "CURRENTLY PLAYING", 46 - refreshInterval = 15000, 47 42 handle, 48 43 }) => { 49 44 const [albumArt, setAlbumArt] = useState<string | undefined>(undefined); 50 45 const [artworkLoading, setArtworkLoading] = useState(true); 51 46 const [songlinkData, setSonglinkData] = useState<SonglinkResponse | undefined>(undefined); 52 47 const [showPlatformModal, setShowPlatformModal] = useState(false); 53 - const [refreshKey, setRefreshKey] = useState(0); 48 + const previousTrackIdentityRef = useRef<string>(""); 54 49 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]); 50 + // Auto-refresh interval removed - handled by AtProtoRecord 67 51 68 52 useEffect(() => { 69 53 if (!record) return; ··· 77 61 return; 78 62 } 79 63 80 - // Reset loading state at start of fetch 81 - if (refreshKey > 0) { 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}`); 82 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})`); 83 82 } 84 83 85 84 let cancelled = false; ··· 100 99 // Extract album art from Songlink data 101 100 const entityId = data.entityUniqueId; 102 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 + 103 106 if (entity?.thumbnailUrl) { 104 107 console.log(`[teal.fm] ✓ Found album art via ISRC lookup`); 105 108 setAlbumArt(entity.thumbnailUrl); 106 109 } else { 107 - console.warn(`[teal.fm] ISRC lookup succeeded but no thumbnail found`); 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 + }); 108 115 } 109 116 setArtworkLoading(false); 110 117 return; ··· 187 194 if (!albumArt) { 188 195 const entityId = data.entityUniqueId; 189 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 + 190 201 if (entity?.thumbnailUrl) { 191 202 console.log(`[teal.fm] ✓ Found album art via Songlink originUrl lookup`); 192 203 setAlbumArt(entity.thumbnailUrl); 193 204 } else { 194 - console.warn(`[teal.fm] Songlink lookup succeeded but no thumbnail found`); 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 + }); 195 210 } 196 211 } 197 212 } else { ··· 215 230 return () => { 216 231 cancelled = true; 217 232 }; 218 - }, [record, refreshKey]); // Add refreshKey to trigger refetch 233 + }, [record]); // Runs on record change 219 234 220 235 if (error) 221 236 return ( ··· 266 281 267 282 const artistNames = item.artists.map((a) => a.artistName).join(", "); 268 283 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" }, 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 + }, 276 315 }; 277 316 278 317 const availablePlatforms = songlinkData ··· 420 459 onClick={() => setShowPlatformModal(false)} 421 460 data-teal-platform="true" 422 461 > 423 - <span style={styles.platformIcon}>{config.icon}</span> 462 + <span 463 + style={styles.platformIcon} 464 + dangerouslySetInnerHTML={{ __html: config.svg }} 465 + /> 424 466 <span style={styles.platformName}>{config.name}</span> 425 467 <svg 426 468 width="20"
+13
lib/utils/cache.ts
··· 290 290 export class RecordCache { 291 291 private store = new Map<string, RecordCacheEntry>(); 292 292 private inFlight = new Map<string, InFlightRecordEntry>(); 293 + // Collections that should not be cached (e.g., status records that change frequently) 294 + private noCacheCollections = new Set<string>([ 295 + "fm.teal.alpha.actor.status", 296 + "fm.teal.alpha.feed.play", 297 + ]); 293 298 294 299 private key(did: string, collection: string, rkey: string): string { 295 300 return `${did}::${collection}::${rkey}`; 296 301 } 297 302 303 + private shouldCache(collection: string): boolean { 304 + return !this.noCacheCollections.has(collection); 305 + } 306 + 298 307 get<T = unknown>( 299 308 did?: string, 300 309 collection?: string, 301 310 rkey?: string, 302 311 ): T | undefined { 303 312 if (!did || !collection || !rkey) return undefined; 313 + // Don't return cached data for non-cacheable collections 314 + if (!this.shouldCache(collection)) return undefined; 304 315 return this.store.get(this.key(did, collection, rkey))?.record as 305 316 | T 306 317 | undefined; ··· 312 323 rkey: string, 313 324 record: T, 314 325 ): void { 326 + // Don't cache records for non-cacheable collections 327 + if (!this.shouldCache(collection)) return; 315 328 this.store.set(this.key(did, collection, rkey), { 316 329 record, 317 330 timestamp: Date.now(),