···2222 loadingIndicator?: React.ReactNode;
2323 /** Preferred color scheme for theming. */
2424 colorScheme?: "light" | "dark" | "system";
2525- /** Auto-refresh music data and album art every 15 seconds. Defaults to true. */
2525+ /** Auto-refresh music data and album art. When true, refreshes every 15 seconds. Defaults to true. */
2626 autoRefresh?: boolean;
2727+ /** Refresh interval in milliseconds. Defaults to 15000 (15 seconds). Only used when autoRefresh is true. */
2828+ refreshInterval?: number;
2729}
28302931/**
···4244 did: string;
4345 /** Record key for the status. */
4446 rkey: string;
4545- /** Auto-refresh music data and album art every 15 seconds. */
4646- autoRefresh?: boolean;
4747 /** Label to display. */
4848 label?: string;
4949- /** Refresh interval in milliseconds. */
5050- refreshInterval?: number;
5149 /** Handle to display in not listening state */
5250 handle?: string;
5351};
···5654export const CURRENTLY_PLAYING_COLLECTION = "fm.teal.alpha.actor.status";
57555856/**
5757+ * Compares two teal.fm status records to determine if the track has changed.
5858+ * Used to prevent unnecessary re-renders during auto-refresh when the same track is still playing.
5959+ */
6060+const compareTealRecords = (
6161+ prev: TealActorStatusRecord | undefined,
6262+ next: TealActorStatusRecord | undefined
6363+): boolean => {
6464+ if (!prev || !next) return prev === next;
6565+6666+ const prevTrack = prev.item.trackName;
6767+ const nextTrack = next.item.trackName;
6868+ const prevArtist = prev.item.artists[0]?.artistName;
6969+ const nextArtist = next.item.artists[0]?.artistName;
7070+7171+ return prevTrack === nextTrack && prevArtist === nextArtist;
7272+};
7373+7474+/**
5975 * Displays the currently playing track from teal.fm with auto-refresh.
6076 *
6177 * @param did - DID whose currently playing status should be fetched.
···6480 * @param fallback - Node rendered before the first load begins.
6581 * @param loadingIndicator - Node rendered while the status is loading.
6682 * @param colorScheme - Preferred color scheme for theming the renderer.
6767- * @param autoRefresh - When true (default), refreshes album art and streaming platform links every 15 seconds.
8383+ * @param autoRefresh - When true (default), refreshes the record every 15 seconds (or custom interval).
8484+ * @param refreshInterval - Custom refresh interval in milliseconds. Defaults to 15000 (15 seconds).
6885 * @returns A JSX subtree representing the currently playing track with loading states handled.
6986 */
7087export const CurrentlyPlaying: React.FC<CurrentlyPlayingProps> = React.memo(({
···7693 loadingIndicator,
7794 colorScheme,
7895 autoRefresh = true,
9696+ refreshInterval = 15000,
7997}) => {
8098 // Resolve handle from DID
8199 const { handle } = useDidResolution(did);
···92110 colorScheme={colorScheme}
93111 did={did}
94112 rkey={rkey}
9595- autoRefresh={autoRefresh}
96113 label="CURRENTLY PLAYING"
9797- refreshInterval={15000}
98114 handle={handle}
99115 />
100116 );
···118134 renderer={Wrapped}
119135 fallback={fallback}
120136 loadingIndicator={loadingIndicator}
137137+ refreshInterval={autoRefresh ? refreshInterval : undefined}
138138+ compareRecords={compareTealRecords}
121139 />
122140 );
123141});
+18-9
lib/components/LastPlayed.tsx
···22import { useLatestRecord } from "../hooks/useLatestRecord";
33import { useDidResolution } from "../hooks/useDidResolution";
44import { CurrentlyPlayingRenderer } from "../renderers/CurrentlyPlayingRenderer";
55-import type { TealFeedPlayRecord } from "../types/teal";
55+import type { TealFeedPlayRecord, TealActorStatusRecord } from "../types/teal";
6677/**
88 * Props for rendering the last played track from teal.fm feed.
···2929 */
3030export type LastPlayedRendererInjectedProps = {
3131 /** Loaded teal.fm feed play record value. */
3232- record: TealFeedPlayRecord;
3232+ record: TealActorStatusRecord;
3333 /** Indicates whether the record is currently loading. */
3434 loading: boolean;
3535 /** Fetch error, if any. */
···4040 did: string;
4141 /** Record key for the play record. */
4242 rkey: string;
4343- /** Auto-refresh music data and album art. */
4444- autoRefresh?: boolean;
4545- /** Refresh interval in milliseconds. */
4646- refreshInterval?: number;
4743 /** Handle to display in not listening state */
4844 handle?: string;
4945};
···7571 // Resolve handle from DID
7672 const { handle } = useDidResolution(did);
77737474+ // Auto-refresh key for refetching teal.fm record
7575+ const [refreshKey, setRefreshKey] = React.useState(0);
7676+7777+ // Auto-refresh interval
7878+ React.useEffect(() => {
7979+ if (!autoRefresh) return;
8080+8181+ const interval = setInterval(() => {
8282+ setRefreshKey((prev) => prev + 1);
8383+ }, refreshInterval);
8484+8585+ return () => clearInterval(interval);
8686+ }, [autoRefresh, refreshInterval]);
8787+7888 const { record, rkey, loading, error, empty } = useLatestRecord<TealFeedPlayRecord>(
7989 did,
8080- LAST_PLAYED_COLLECTION
9090+ LAST_PLAYED_COLLECTION,
9191+ refreshKey,
8192 );
82938394 // Normalize TealFeedPlayRecord to match TealActorStatusRecord structure
···145156 colorScheme={colorScheme}
146157 did={did}
147158 rkey={rkey || "unknown"}
148148- autoRefresh={autoRefresh}
149159 label="LAST PLAYED"
150150- refreshInterval={refreshInterval}
151160 handle={handle}
152161 />
153162 );
+68-6
lib/core/AtProtoRecord.tsx
···11-import React from "react";
11+import React, { useState, useEffect, useRef } from "react";
22import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
3344/**
···1515 fallback?: React.ReactNode;
1616 /** React node shown while the record is being fetched. */
1717 loadingIndicator?: React.ReactNode;
1818+ /** Auto-refresh interval in milliseconds. When set, the record will be refetched at this interval. */
1919+ refreshInterval?: number;
2020+ /** Comparison function to determine if a record has changed. Used to prevent unnecessary re-renders during auto-refresh. */
2121+ compareRecords?: (prev: T | undefined, next: T | undefined) => boolean;
1822}
19232024/**
···6165 *
6266 * When no custom renderer is provided, displays the record as formatted JSON.
6367 *
6868+ * **Auto-refresh**: Set `refreshInterval` to automatically refetch the record at the specified interval.
6969+ * The component intelligently avoids re-rendering if the record hasn't changed (using `compareRecords`).
7070+ *
6471 * @example
6572 * ```tsx
6673 * // Fetch mode - retrieves record from network
···8188 * />
8289 * ```
8390 *
9191+ * @example
9292+ * ```tsx
9393+ * // Auto-refresh mode - refetches every 15 seconds
9494+ * <AtProtoRecord
9595+ * did="did:plc:example"
9696+ * collection="fm.teal.alpha.actor.status"
9797+ * rkey="self"
9898+ * refreshInterval={15000}
9999+ * compareRecords={(prev, next) => JSON.stringify(prev) === JSON.stringify(next)}
100100+ * renderer={MyCustomRenderer}
101101+ * />
102102+ * ```
103103+ *
84104 * @param props - Either fetch props (did/collection/rkey) or prefetch props (record).
85105 * @returns A rendered AT Protocol record with loading/error states handled.
86106 */
···89109 renderer: Renderer,
90110 fallback = null,
91111 loadingIndicator = "Loading…",
112112+ refreshInterval,
113113+ compareRecords,
92114 } = props;
93115 const hasProvidedRecord = "record" in props;
94116 const providedRecord = hasProvidedRecord ? props.record : undefined;
95117118118+ // Extract fetch props for logging
119119+ const fetchDid = hasProvidedRecord ? undefined : (props as any).did;
120120+ const fetchCollection = hasProvidedRecord ? undefined : (props as any).collection;
121121+ const fetchRkey = hasProvidedRecord ? undefined : (props as any).rkey;
122122+123123+ // State for managing auto-refresh
124124+ const [refreshKey, setRefreshKey] = useState(0);
125125+ const [stableRecord, setStableRecord] = useState<T | undefined>(providedRecord);
126126+ const previousRecordRef = useRef<T | undefined>(providedRecord);
127127+128128+ // Auto-refresh interval
129129+ useEffect(() => {
130130+ if (!refreshInterval || hasProvidedRecord) return;
131131+132132+ const interval = setInterval(() => {
133133+ setRefreshKey((prev) => prev + 1);
134134+ }, refreshInterval);
135135+136136+ return () => clearInterval(interval);
137137+ }, [refreshInterval, hasProvidedRecord, fetchCollection, fetchDid]);
138138+96139 const {
97140 record: fetchedRecord,
98141 error,
99142 loading,
100143 } = useAtProtoRecord<T>({
101101- did: hasProvidedRecord ? undefined : props.did,
102102- collection: hasProvidedRecord ? undefined : props.collection,
103103- rkey: hasProvidedRecord ? undefined : props.rkey,
144144+ did: fetchDid,
145145+ collection: fetchCollection,
146146+ rkey: fetchRkey,
147147+ bypassCache: !!refreshInterval && refreshKey > 0, // Bypass cache on auto-refresh (but not initial load)
148148+ _refreshKey: refreshKey, // Force hook to re-run
104149 });
105150106106- const record = providedRecord ?? fetchedRecord;
107107- const isLoading = loading && !providedRecord;
151151+ // Determine which record to use
152152+ const currentRecord = providedRecord ?? fetchedRecord;
153153+154154+ // Handle record changes with optional comparison
155155+ useEffect(() => {
156156+ if (!currentRecord) return;
157157+158158+ const hasChanged = compareRecords
159159+ ? !compareRecords(previousRecordRef.current, currentRecord)
160160+ : previousRecordRef.current !== currentRecord;
161161+162162+ if (hasChanged) {
163163+ setStableRecord(currentRecord);
164164+ previousRecordRef.current = currentRecord;
165165+ }
166166+ }, [currentRecord, compareRecords]);
167167+168168+ const record = stableRecord;
169169+ const isLoading = loading && !providedRecord && !stableRecord;
108170109171 if (error && !record) return <>{fallback}</>;
110172 if (!record) return <>{isLoading ? loadingIndicator : fallback}</>;
+70
lib/hooks/useAtProtoRecord.ts
···1515 collection?: string;
1616 /** Record key string uniquely identifying the record within the collection. */
1717 rkey?: string;
1818+ /** Force bypass cache and refetch from network. Useful for auto-refresh scenarios. */
1919+ bypassCache?: boolean;
2020+ /** Internal refresh trigger - changes to this value force a refetch. */
2121+ _refreshKey?: number;
1822}
19232024/**
···4246 * @param did - DID (or handle before resolution) that owns the record.
4347 * @param collection - NSID collection from which to fetch the record.
4448 * @param rkey - Record key identifying the record within the collection.
4949+ * @param bypassCache - Force bypass cache and refetch from network. Useful for auto-refresh scenarios.
5050+ * @param _refreshKey - Internal parameter used to trigger refetches.
4551 * @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag.
4652 */
4753export function useAtProtoRecord<T = unknown>({
4854 did: handleOrDid,
4955 collection,
5056 rkey,
5757+ bypassCache = false,
5858+ _refreshKey = 0,
5159}: AtProtoRecordKey): AtProtoRecordState<T> {
5260 const { recordCache } = useAtProto();
5361 const isBlueskyCollection = collection?.startsWith("app.bsky.");
···133141134142 assignState({ loading: true, error: undefined, record: undefined });
135143144144+ // Bypass cache if requested (for auto-refresh scenarios)
145145+ if (bypassCache) {
146146+ assignState({ loading: true, error: undefined });
147147+148148+ // Skip cache and fetch directly
149149+ const controller = new AbortController();
150150+151151+ const fetchPromise = (async () => {
152152+ try {
153153+ const { rpc } = await createAtprotoClient({
154154+ service: endpoint,
155155+ });
156156+ const res = await (
157157+ rpc as unknown as {
158158+ get: (
159159+ nsid: string,
160160+ opts: {
161161+ params: {
162162+ repo: string;
163163+ collection: string;
164164+ rkey: string;
165165+ };
166166+ },
167167+ ) => Promise<{ ok: boolean; data: { value: T } }>;
168168+ }
169169+ ).get("com.atproto.repo.getRecord", {
170170+ params: { repo: did, collection, rkey },
171171+ });
172172+ if (!res.ok) throw new Error("Failed to load record");
173173+ return (res.data as { value: T }).value;
174174+ } catch (err) {
175175+ // Provide helpful error for banned/unreachable Bluesky PDSes
176176+ if (endpoint.includes('.bsky.network')) {
177177+ throw new Error(
178178+ `Record unavailable. The Bluesky PDS (${endpoint}) may be unreachable or the account may be banned.`
179179+ );
180180+ }
181181+ throw err;
182182+ }
183183+ })();
184184+185185+ fetchPromise
186186+ .then((record) => {
187187+ if (!cancelled) {
188188+ assignState({ record, loading: false });
189189+ }
190190+ })
191191+ .catch((e) => {
192192+ if (!cancelled) {
193193+ const err = e instanceof Error ? e : new Error(String(e));
194194+ assignState({ error: err, loading: false });
195195+ }
196196+ });
197197+198198+ return () => {
199199+ cancelled = true;
200200+ controller.abort();
201201+ };
202202+ }
203203+136204 // Use recordCache.ensure for deduplication and caching
137205 const { promise, release } = recordCache.ensure<T>(
138206 did,
···215283 didError,
216284 endpointError,
217285 recordCache,
286286+ bypassCache,
287287+ _refreshKey,
218288 ]);
219289220290 // Return Bluesky result for app.bsky.* collections
+26-7
lib/hooks/useBlueskyAppview.ts
···236236}: UseBlueskyAppviewOptions): UseBlueskyAppviewResult<T> {
237237 const { recordCache, blueskyAppviewService, resolver } = useAtProto();
238238 const effectiveAppviewService = appviewService ?? blueskyAppviewService;
239239+240240+ // Only use this hook for Bluesky collections (app.bsky.*)
241241+ const isBlueskyCollection = collection?.startsWith("app.bsky.");
242242+239243 const {
240244 did,
241245 error: didError,
···261265262266 // Early returns for missing inputs or resolution errors
263267 if (!handleOrDid || !collection || !rkey) {
268268+ if (!cancelled) dispatch({ type: "RESET" });
269269+ return () => {
270270+ cancelled = true;
271271+ if (releaseRef.current) {
272272+ releaseRef.current();
273273+ releaseRef.current = undefined;
274274+ }
275275+ };
276276+ }
277277+278278+ // Return early if not a Bluesky collection - this hook should not be used for other lexicons
279279+ if (!isBlueskyCollection) {
264280 if (!cancelled) dispatch({ type: "RESET" });
265281 return () => {
266282 cancelled = true;
···683699 };
684700}> {
685701 const { rpc } = await createAtprotoClient({ service });
702702+703703+ const params: Record<string, unknown> = {
704704+ repo: did,
705705+ collection,
706706+ limit,
707707+ cursor,
708708+ reverse: false,
709709+ };
710710+686711 return await (rpc as unknown as {
687712 get: (
688713 nsid: string,
···695720 };
696721 }>;
697722 }).get("com.atproto.repo.listRecords", {
698698- params: {
699699- repo: did,
700700- collection,
701701- limit,
702702- cursor,
703703- reverse: false,
704704- },
723723+ params,
705724 });
706725}
707726
+5-2
lib/hooks/useLatestRecord.ts
···21212222/**
2323 * Fetches the most recent record from a collection using `listRecords(limit=3)`.
2424- *
2424+ *
2525 * Note: Slingshot does not support listRecords, so this always queries the actor's PDS directly.
2626- *
2626+ *
2727 * Records with invalid timestamps (before 2023, when ATProto was created) are automatically
2828 * skipped, and additional records are fetched to find a valid one.
2929 *
3030 * @param handleOrDid - Handle or DID that owns the collection.
3131 * @param collection - NSID of the collection to query.
3232+ * @param refreshKey - Optional key that when changed, triggers a refetch. Use for auto-refresh scenarios.
3233 * @returns {LatestRecordState<T>} Object reporting the latest record value, derived rkey, loading status, emptiness, and any error.
3334 */
3435export function useLatestRecord<T = unknown>(
3536 handleOrDid: string | undefined,
3637 collection: string,
3838+ refreshKey?: number,
3739): LatestRecordState<T> {
3840 const {
3941 did,
···157159 resolvingEndpoint,
158160 didError,
159161 endpointError,
162162+ refreshKey,
160163 ]);
161164162165 return state;