A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useMemo } from "react"; 2import { useLatestRecord } from "../hooks/useLatestRecord"; 3import { useDidResolution } from "../hooks/useDidResolution"; 4import { CurrentlyPlayingRenderer } from "../renderers/CurrentlyPlayingRenderer"; 5import type { TealFeedPlayRecord, TealActorStatusRecord } from "../types/teal"; 6 7/** 8 * Props for rendering the last played track from teal.fm feed. 9 */ 10export interface LastPlayedProps { 11 /** DID of the user whose last played track to display. */ 12 did: string; 13 /** Optional renderer override for custom presentation. */ 14 renderer?: React.ComponentType<LastPlayedRendererInjectedProps>; 15 /** Fallback node displayed before loading begins. */ 16 fallback?: React.ReactNode; 17 /** Indicator node shown while data is loading. */ 18 loadingIndicator?: React.ReactNode; 19 /** Preferred color scheme for theming. */ 20 colorScheme?: "light" | "dark" | "system"; 21 /** Auto-refresh music data and album art. Defaults to false for last played. */ 22 autoRefresh?: boolean; 23 /** Refresh interval in milliseconds. Defaults to 60000 (60 seconds). */ 24 refreshInterval?: number; 25} 26 27/** 28 * Values injected into custom last played renderer implementations. 29 */ 30export type LastPlayedRendererInjectedProps = { 31 /** Loaded teal.fm feed play record value. */ 32 record: TealActorStatusRecord; 33 /** Indicates whether the record is currently loading. */ 34 loading: boolean; 35 /** Fetch error, if any. */ 36 error?: Error; 37 /** Preferred color scheme for downstream components. */ 38 colorScheme?: "light" | "dark" | "system"; 39 /** DID associated with the record. */ 40 did: string; 41 /** Record key for the play record. */ 42 rkey: string; 43 /** Handle to display in not listening state */ 44 handle?: string; 45}; 46 47/** NSID for teal.fm feed play records. */ 48export const LAST_PLAYED_COLLECTION = "fm.teal.alpha.feed.play"; 49 50/** 51 * Displays the last played track from teal.fm feed. 52 * 53 * @param did - DID whose last played track should be fetched. 54 * @param renderer - Optional component override that will receive injected props. 55 * @param fallback - Node rendered before the first load begins. 56 * @param loadingIndicator - Node rendered while the data is loading. 57 * @param colorScheme - Preferred color scheme for theming the renderer. 58 * @param autoRefresh - When true, refreshes album art and streaming platform links at the specified interval. Defaults to false. 59 * @param refreshInterval - Refresh interval in milliseconds. Defaults to 60000 (60 seconds). 60 * @returns A JSX subtree representing the last played track with loading states handled. 61 */ 62export const LastPlayed: React.FC<LastPlayedProps> = React.memo(({ 63 did, 64 renderer, 65 fallback, 66 loadingIndicator, 67 colorScheme, 68 autoRefresh = false, 69 refreshInterval = 60000, 70}) => { 71 // Resolve handle from DID 72 const { handle } = useDidResolution(did); 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 88 const { record, rkey, loading, error, empty } = useLatestRecord<TealFeedPlayRecord>( 89 did, 90 LAST_PLAYED_COLLECTION, 91 refreshKey, 92 ); 93 94 // Normalize TealFeedPlayRecord to match TealActorStatusRecord structure 95 // Use useMemo to prevent creating new object on every render 96 // MUST be called before any conditional returns (Rules of Hooks) 97 const normalizedRecord = useMemo(() => { 98 if (!record) return null; 99 100 return { 101 $type: "fm.teal.alpha.actor.status" as const, 102 item: { 103 artists: record.artists, 104 originUrl: record.originUrl, 105 trackName: record.trackName, 106 playedTime: record.playedTime, 107 releaseName: record.releaseName, 108 recordingMbId: record.recordingMbId, 109 releaseMbId: record.releaseMbId, 110 submissionClientAgent: record.submissionClientAgent, 111 musicServiceBaseDomain: record.musicServiceBaseDomain, 112 isrc: record.isrc, 113 duration: record.duration, 114 }, 115 time: new Date(record.playedTime).getTime().toString(), 116 expiry: undefined, 117 }; 118 }, [record]); 119 120 const Comp = renderer ?? CurrentlyPlayingRenderer; 121 122 // Now handle conditional returns after all hooks 123 if (error) { 124 return ( 125 <div style={{ padding: 8, color: "var(--atproto-color-error)" }}> 126 Failed to load last played track. 127 </div> 128 ); 129 } 130 131 if (loading && !record) { 132 return loadingIndicator ? ( 133 <>{loadingIndicator}</> 134 ) : ( 135 <div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}> 136 Loading 137 </div> 138 ); 139 } 140 141 if (empty || !record || !normalizedRecord) { 142 return fallback ? ( 143 <>{fallback}</> 144 ) : ( 145 <div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}> 146 No plays found. 147 </div> 148 ); 149 } 150 151 return ( 152 <Comp 153 record={normalizedRecord} 154 loading={loading} 155 error={error} 156 colorScheme={colorScheme} 157 did={did} 158 rkey={rkey || "unknown"} 159 label="LAST PLAYED" 160 handle={handle} 161 /> 162 ); 163}); 164 165export default LastPlayed;