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 } 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: TealFeedPlayRecord; 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 /** Auto-refresh music data and album art. */ 44 autoRefresh?: boolean; 45 /** Refresh interval in milliseconds. */ 46 refreshInterval?: number; 47 /** Handle to display in not listening state */ 48 handle?: string; 49}; 50 51/** NSID for teal.fm feed play records. */ 52export const LAST_PLAYED_COLLECTION = "fm.teal.alpha.feed.play"; 53 54/** 55 * Displays the last played track from teal.fm feed. 56 * 57 * @param did - DID whose last played track should be fetched. 58 * @param renderer - Optional component override that will receive injected props. 59 * @param fallback - Node rendered before the first load begins. 60 * @param loadingIndicator - Node rendered while the data is loading. 61 * @param colorScheme - Preferred color scheme for theming the renderer. 62 * @param autoRefresh - When true, refreshes album art and streaming platform links at the specified interval. Defaults to false. 63 * @param refreshInterval - Refresh interval in milliseconds. Defaults to 60000 (60 seconds). 64 * @returns A JSX subtree representing the last played track with loading states handled. 65 */ 66export const LastPlayed: React.FC<LastPlayedProps> = React.memo(({ 67 did, 68 renderer, 69 fallback, 70 loadingIndicator, 71 colorScheme, 72 autoRefresh = false, 73 refreshInterval = 60000, 74}) => { 75 // Resolve handle from DID 76 const { handle } = useDidResolution(did); 77 78 const { record, rkey, loading, error, empty } = useLatestRecord<TealFeedPlayRecord>( 79 did, 80 LAST_PLAYED_COLLECTION 81 ); 82 83 // Normalize TealFeedPlayRecord to match TealActorStatusRecord structure 84 // Use useMemo to prevent creating new object on every render 85 // MUST be called before any conditional returns (Rules of Hooks) 86 const normalizedRecord = useMemo(() => { 87 if (!record) return null; 88 89 return { 90 $type: "fm.teal.alpha.actor.status" as const, 91 item: { 92 artists: record.artists, 93 originUrl: record.originUrl, 94 trackName: record.trackName, 95 playedTime: record.playedTime, 96 releaseName: record.releaseName, 97 recordingMbId: record.recordingMbId, 98 releaseMbId: record.releaseMbId, 99 submissionClientAgent: record.submissionClientAgent, 100 musicServiceBaseDomain: record.musicServiceBaseDomain, 101 isrc: record.isrc, 102 duration: record.duration, 103 }, 104 time: new Date(record.playedTime).getTime().toString(), 105 expiry: undefined, 106 }; 107 }, [record]); 108 109 const Comp = renderer ?? CurrentlyPlayingRenderer; 110 111 // Now handle conditional returns after all hooks 112 if (error) { 113 return ( 114 <div style={{ padding: 8, color: "var(--atproto-color-error)" }}> 115 Failed to load last played track. 116 </div> 117 ); 118 } 119 120 if (loading && !record) { 121 return loadingIndicator ? ( 122 <>{loadingIndicator}</> 123 ) : ( 124 <div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}> 125 Loading 126 </div> 127 ); 128 } 129 130 if (empty || !record || !normalizedRecord) { 131 return fallback ? ( 132 <>{fallback}</> 133 ) : ( 134 <div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}> 135 No plays found. 136 </div> 137 ); 138 } 139 140 return ( 141 <Comp 142 record={normalizedRecord} 143 loading={loading} 144 error={error} 145 colorScheme={colorScheme} 146 did={did} 147 rkey={rkey || "unknown"} 148 autoRefresh={autoRefresh} 149 label="LAST PLAYED" 150 refreshInterval={refreshInterval} 151 handle={handle} 152 /> 153 ); 154}); 155 156export default LastPlayed;