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;