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;