A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React from "react";
2import { AtProtoRecord } from "../core/AtProtoRecord";
3import { CurrentlyPlayingRenderer } from "../renderers/CurrentlyPlayingRenderer";
4import { useDidResolution } from "../hooks/useDidResolution";
5import type { TealActorStatusRecord } from "../types/teal";
6
7/**
8 * Props for rendering teal.fm currently playing status.
9 */
10export interface CurrentlyPlayingProps {
11 /** DID of the user whose currently playing status to display. */
12 did: string;
13 /** Record key within the `fm.teal.alpha.actor.status` collection (usually "self"). */
14 rkey?: string;
15 /** Prefetched teal.fm status record. When provided, skips fetching from the network. */
16 record?: TealActorStatusRecord;
17 /** Optional renderer override for custom presentation. */
18 renderer?: React.ComponentType<CurrentlyPlayingRendererInjectedProps>;
19 /** Fallback node displayed before loading begins. */
20 fallback?: React.ReactNode;
21 /** Indicator node shown while data is loading. */
22 loadingIndicator?: React.ReactNode;
23 /** Preferred color scheme for theming. */
24 colorScheme?: "light" | "dark" | "system";
25 /** Auto-refresh music data and album art. When true, refreshes every 15 seconds. Defaults to true. */
26 autoRefresh?: boolean;
27 /** Refresh interval in milliseconds. Defaults to 15000 (15 seconds). Only used when autoRefresh is true. */
28 refreshInterval?: number;
29}
30
31/**
32 * Values injected into custom currently playing renderer implementations.
33 */
34export type CurrentlyPlayingRendererInjectedProps = {
35 /** Loaded teal.fm status record value. */
36 record: TealActorStatusRecord;
37 /** Indicates whether the record is currently loading. */
38 loading: boolean;
39 /** Fetch error, if any. */
40 error?: Error;
41 /** Preferred color scheme for downstream components. */
42 colorScheme?: "light" | "dark" | "system";
43 /** DID associated with the record. */
44 did: string;
45 /** Record key for the status. */
46 rkey: string;
47 /** Label to display. */
48 label?: string;
49 /** Handle to display in not listening state */
50 handle?: string;
51};
52
53/** NSID for teal.fm actor status records. */
54export const CURRENTLY_PLAYING_COLLECTION = "fm.teal.alpha.actor.status";
55
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 */
60const 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/**
75 * Displays the currently playing track from teal.fm with auto-refresh.
76 *
77 * @param did - DID whose currently playing status should be fetched.
78 * @param rkey - Record key within the teal.fm status collection (defaults to "self").
79 * @param renderer - Optional component override that will receive injected props.
80 * @param fallback - Node rendered before the first load begins.
81 * @param loadingIndicator - Node rendered while the status is loading.
82 * @param colorScheme - Preferred color scheme for theming the renderer.
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).
85 * @returns A JSX subtree representing the currently playing track with loading states handled.
86 */
87export const CurrentlyPlaying: React.FC<CurrentlyPlayingProps> = React.memo(({
88 did,
89 rkey = "self",
90 record,
91 renderer,
92 fallback,
93 loadingIndicator,
94 colorScheme,
95 autoRefresh = true,
96 refreshInterval = 15000,
97}) => {
98 // Resolve handle from DID
99 const { handle } = useDidResolution(did);
100
101 const Comp: React.ComponentType<CurrentlyPlayingRendererInjectedProps> =
102 renderer ?? ((props) => <CurrentlyPlayingRenderer {...props} />);
103 const Wrapped: React.FC<{
104 record: TealActorStatusRecord;
105 loading: boolean;
106 error?: Error;
107 }> = (props) => (
108 <Comp
109 {...props}
110 colorScheme={colorScheme}
111 did={did}
112 rkey={rkey}
113 label="CURRENTLY PLAYING"
114 handle={handle}
115 />
116 );
117
118 if (record !== undefined) {
119 return (
120 <AtProtoRecord<TealActorStatusRecord>
121 record={record}
122 renderer={Wrapped}
123 fallback={fallback}
124 loadingIndicator={loadingIndicator}
125 />
126 );
127 }
128
129 return (
130 <AtProtoRecord<TealActorStatusRecord>
131 did={did}
132 collection={CURRENTLY_PLAYING_COLLECTION}
133 rkey={rkey}
134 renderer={Wrapped}
135 fallback={fallback}
136 loadingIndicator={loadingIndicator}
137 refreshInterval={autoRefresh ? refreshInterval : undefined}
138 compareRecords={compareTealRecords}
139 />
140 );
141});
142
143export default CurrentlyPlaying;