+26
-8
lib/components/CurrentlyPlaying.tsx
+26
-8
lib/components/CurrentlyPlaying.tsx
···
22
22
loadingIndicator?: React.ReactNode;
23
23
/** Preferred color scheme for theming. */
24
24
colorScheme?: "light" | "dark" | "system";
25
-
/** Auto-refresh music data and album art every 15 seconds. Defaults to true. */
25
+
/** Auto-refresh music data and album art. When true, refreshes every 15 seconds. Defaults to true. */
26
26
autoRefresh?: boolean;
27
+
/** Refresh interval in milliseconds. Defaults to 15000 (15 seconds). Only used when autoRefresh is true. */
28
+
refreshInterval?: number;
27
29
}
28
30
29
31
/**
···
42
44
did: string;
43
45
/** Record key for the status. */
44
46
rkey: string;
45
-
/** Auto-refresh music data and album art every 15 seconds. */
46
-
autoRefresh?: boolean;
47
47
/** Label to display. */
48
48
label?: string;
49
-
/** Refresh interval in milliseconds. */
50
-
refreshInterval?: number;
51
49
/** Handle to display in not listening state */
52
50
handle?: string;
53
51
};
···
56
54
export const CURRENTLY_PLAYING_COLLECTION = "fm.teal.alpha.actor.status";
57
55
58
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
+
*/
60
+
const 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
+
/**
59
75
* Displays the currently playing track from teal.fm with auto-refresh.
60
76
*
61
77
* @param did - DID whose currently playing status should be fetched.
···
64
80
* @param fallback - Node rendered before the first load begins.
65
81
* @param loadingIndicator - Node rendered while the status is loading.
66
82
* @param colorScheme - Preferred color scheme for theming the renderer.
67
-
* @param autoRefresh - When true (default), refreshes album art and streaming platform links every 15 seconds.
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).
68
85
* @returns A JSX subtree representing the currently playing track with loading states handled.
69
86
*/
70
87
export const CurrentlyPlaying: React.FC<CurrentlyPlayingProps> = React.memo(({
···
76
93
loadingIndicator,
77
94
colorScheme,
78
95
autoRefresh = true,
96
+
refreshInterval = 15000,
79
97
}) => {
80
98
// Resolve handle from DID
81
99
const { handle } = useDidResolution(did);
···
92
110
colorScheme={colorScheme}
93
111
did={did}
94
112
rkey={rkey}
95
-
autoRefresh={autoRefresh}
96
113
label="CURRENTLY PLAYING"
97
-
refreshInterval={15000}
98
114
handle={handle}
99
115
/>
100
116
);
···
118
134
renderer={Wrapped}
119
135
fallback={fallback}
120
136
loadingIndicator={loadingIndicator}
137
+
refreshInterval={autoRefresh ? refreshInterval : undefined}
138
+
compareRecords={compareTealRecords}
121
139
/>
122
140
);
123
141
});
+18
-9
lib/components/LastPlayed.tsx
+18
-9
lib/components/LastPlayed.tsx
···
2
2
import { useLatestRecord } from "../hooks/useLatestRecord";
3
3
import { useDidResolution } from "../hooks/useDidResolution";
4
4
import { CurrentlyPlayingRenderer } from "../renderers/CurrentlyPlayingRenderer";
5
-
import type { TealFeedPlayRecord } from "../types/teal";
5
+
import type { TealFeedPlayRecord, TealActorStatusRecord } from "../types/teal";
6
6
7
7
/**
8
8
* Props for rendering the last played track from teal.fm feed.
···
29
29
*/
30
30
export type LastPlayedRendererInjectedProps = {
31
31
/** Loaded teal.fm feed play record value. */
32
-
record: TealFeedPlayRecord;
32
+
record: TealActorStatusRecord;
33
33
/** Indicates whether the record is currently loading. */
34
34
loading: boolean;
35
35
/** Fetch error, if any. */
···
40
40
did: string;
41
41
/** Record key for the play record. */
42
42
rkey: string;
43
-
/** Auto-refresh music data and album art. */
44
-
autoRefresh?: boolean;
45
-
/** Refresh interval in milliseconds. */
46
-
refreshInterval?: number;
47
43
/** Handle to display in not listening state */
48
44
handle?: string;
49
45
};
···
75
71
// Resolve handle from DID
76
72
const { handle } = useDidResolution(did);
77
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
+
78
88
const { record, rkey, loading, error, empty } = useLatestRecord<TealFeedPlayRecord>(
79
89
did,
80
-
LAST_PLAYED_COLLECTION
90
+
LAST_PLAYED_COLLECTION,
91
+
refreshKey,
81
92
);
82
93
83
94
// Normalize TealFeedPlayRecord to match TealActorStatusRecord structure
···
145
156
colorScheme={colorScheme}
146
157
did={did}
147
158
rkey={rkey || "unknown"}
148
-
autoRefresh={autoRefresh}
149
159
label="LAST PLAYED"
150
-
refreshInterval={refreshInterval}
151
160
handle={handle}
152
161
/>
153
162
);
+68
-6
lib/core/AtProtoRecord.tsx
+68
-6
lib/core/AtProtoRecord.tsx
···
1
-
import React from "react";
1
+
import React, { useState, useEffect, useRef } from "react";
2
2
import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
3
3
4
4
/**
···
15
15
fallback?: React.ReactNode;
16
16
/** React node shown while the record is being fetched. */
17
17
loadingIndicator?: React.ReactNode;
18
+
/** Auto-refresh interval in milliseconds. When set, the record will be refetched at this interval. */
19
+
refreshInterval?: number;
20
+
/** Comparison function to determine if a record has changed. Used to prevent unnecessary re-renders during auto-refresh. */
21
+
compareRecords?: (prev: T | undefined, next: T | undefined) => boolean;
18
22
}
19
23
20
24
/**
···
61
65
*
62
66
* When no custom renderer is provided, displays the record as formatted JSON.
63
67
*
68
+
* **Auto-refresh**: Set `refreshInterval` to automatically refetch the record at the specified interval.
69
+
* The component intelligently avoids re-rendering if the record hasn't changed (using `compareRecords`).
70
+
*
64
71
* @example
65
72
* ```tsx
66
73
* // Fetch mode - retrieves record from network
···
81
88
* />
82
89
* ```
83
90
*
91
+
* @example
92
+
* ```tsx
93
+
* // Auto-refresh mode - refetches every 15 seconds
94
+
* <AtProtoRecord
95
+
* did="did:plc:example"
96
+
* collection="fm.teal.alpha.actor.status"
97
+
* rkey="self"
98
+
* refreshInterval={15000}
99
+
* compareRecords={(prev, next) => JSON.stringify(prev) === JSON.stringify(next)}
100
+
* renderer={MyCustomRenderer}
101
+
* />
102
+
* ```
103
+
*
84
104
* @param props - Either fetch props (did/collection/rkey) or prefetch props (record).
85
105
* @returns A rendered AT Protocol record with loading/error states handled.
86
106
*/
···
89
109
renderer: Renderer,
90
110
fallback = null,
91
111
loadingIndicator = "Loading…",
112
+
refreshInterval,
113
+
compareRecords,
92
114
} = props;
93
115
const hasProvidedRecord = "record" in props;
94
116
const providedRecord = hasProvidedRecord ? props.record : undefined;
95
117
118
+
// Extract fetch props for logging
119
+
const fetchDid = hasProvidedRecord ? undefined : (props as any).did;
120
+
const fetchCollection = hasProvidedRecord ? undefined : (props as any).collection;
121
+
const fetchRkey = hasProvidedRecord ? undefined : (props as any).rkey;
122
+
123
+
// State for managing auto-refresh
124
+
const [refreshKey, setRefreshKey] = useState(0);
125
+
const [stableRecord, setStableRecord] = useState<T | undefined>(providedRecord);
126
+
const previousRecordRef = useRef<T | undefined>(providedRecord);
127
+
128
+
// Auto-refresh interval
129
+
useEffect(() => {
130
+
if (!refreshInterval || hasProvidedRecord) return;
131
+
132
+
const interval = setInterval(() => {
133
+
setRefreshKey((prev) => prev + 1);
134
+
}, refreshInterval);
135
+
136
+
return () => clearInterval(interval);
137
+
}, [refreshInterval, hasProvidedRecord, fetchCollection, fetchDid]);
138
+
96
139
const {
97
140
record: fetchedRecord,
98
141
error,
99
142
loading,
100
143
} = useAtProtoRecord<T>({
101
-
did: hasProvidedRecord ? undefined : props.did,
102
-
collection: hasProvidedRecord ? undefined : props.collection,
103
-
rkey: hasProvidedRecord ? undefined : props.rkey,
144
+
did: fetchDid,
145
+
collection: fetchCollection,
146
+
rkey: fetchRkey,
147
+
bypassCache: !!refreshInterval && refreshKey > 0, // Bypass cache on auto-refresh (but not initial load)
148
+
_refreshKey: refreshKey, // Force hook to re-run
104
149
});
105
150
106
-
const record = providedRecord ?? fetchedRecord;
107
-
const isLoading = loading && !providedRecord;
151
+
// Determine which record to use
152
+
const currentRecord = providedRecord ?? fetchedRecord;
153
+
154
+
// Handle record changes with optional comparison
155
+
useEffect(() => {
156
+
if (!currentRecord) return;
157
+
158
+
const hasChanged = compareRecords
159
+
? !compareRecords(previousRecordRef.current, currentRecord)
160
+
: previousRecordRef.current !== currentRecord;
161
+
162
+
if (hasChanged) {
163
+
setStableRecord(currentRecord);
164
+
previousRecordRef.current = currentRecord;
165
+
}
166
+
}, [currentRecord, compareRecords]);
167
+
168
+
const record = stableRecord;
169
+
const isLoading = loading && !providedRecord && !stableRecord;
108
170
109
171
if (error && !record) return <>{fallback}</>;
110
172
if (!record) return <>{isLoading ? loadingIndicator : fallback}</>;
+70
lib/hooks/useAtProtoRecord.ts
+70
lib/hooks/useAtProtoRecord.ts
···
15
15
collection?: string;
16
16
/** Record key string uniquely identifying the record within the collection. */
17
17
rkey?: string;
18
+
/** Force bypass cache and refetch from network. Useful for auto-refresh scenarios. */
19
+
bypassCache?: boolean;
20
+
/** Internal refresh trigger - changes to this value force a refetch. */
21
+
_refreshKey?: number;
18
22
}
19
23
20
24
/**
···
42
46
* @param did - DID (or handle before resolution) that owns the record.
43
47
* @param collection - NSID collection from which to fetch the record.
44
48
* @param rkey - Record key identifying the record within the collection.
49
+
* @param bypassCache - Force bypass cache and refetch from network. Useful for auto-refresh scenarios.
50
+
* @param _refreshKey - Internal parameter used to trigger refetches.
45
51
* @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag.
46
52
*/
47
53
export function useAtProtoRecord<T = unknown>({
48
54
did: handleOrDid,
49
55
collection,
50
56
rkey,
57
+
bypassCache = false,
58
+
_refreshKey = 0,
51
59
}: AtProtoRecordKey): AtProtoRecordState<T> {
52
60
const { recordCache } = useAtProto();
53
61
const isBlueskyCollection = collection?.startsWith("app.bsky.");
···
133
141
134
142
assignState({ loading: true, error: undefined, record: undefined });
135
143
144
+
// Bypass cache if requested (for auto-refresh scenarios)
145
+
if (bypassCache) {
146
+
assignState({ loading: true, error: undefined });
147
+
148
+
// Skip cache and fetch directly
149
+
const controller = new AbortController();
150
+
151
+
const fetchPromise = (async () => {
152
+
try {
153
+
const { rpc } = await createAtprotoClient({
154
+
service: endpoint,
155
+
});
156
+
const res = await (
157
+
rpc as unknown as {
158
+
get: (
159
+
nsid: string,
160
+
opts: {
161
+
params: {
162
+
repo: string;
163
+
collection: string;
164
+
rkey: string;
165
+
};
166
+
},
167
+
) => Promise<{ ok: boolean; data: { value: T } }>;
168
+
}
169
+
).get("com.atproto.repo.getRecord", {
170
+
params: { repo: did, collection, rkey },
171
+
});
172
+
if (!res.ok) throw new Error("Failed to load record");
173
+
return (res.data as { value: T }).value;
174
+
} catch (err) {
175
+
// Provide helpful error for banned/unreachable Bluesky PDSes
176
+
if (endpoint.includes('.bsky.network')) {
177
+
throw new Error(
178
+
`Record unavailable. The Bluesky PDS (${endpoint}) may be unreachable or the account may be banned.`
179
+
);
180
+
}
181
+
throw err;
182
+
}
183
+
})();
184
+
185
+
fetchPromise
186
+
.then((record) => {
187
+
if (!cancelled) {
188
+
assignState({ record, loading: false });
189
+
}
190
+
})
191
+
.catch((e) => {
192
+
if (!cancelled) {
193
+
const err = e instanceof Error ? e : new Error(String(e));
194
+
assignState({ error: err, loading: false });
195
+
}
196
+
});
197
+
198
+
return () => {
199
+
cancelled = true;
200
+
controller.abort();
201
+
};
202
+
}
203
+
136
204
// Use recordCache.ensure for deduplication and caching
137
205
const { promise, release } = recordCache.ensure<T>(
138
206
did,
···
215
283
didError,
216
284
endpointError,
217
285
recordCache,
286
+
bypassCache,
287
+
_refreshKey,
218
288
]);
219
289
220
290
// Return Bluesky result for app.bsky.* collections
+26
-7
lib/hooks/useBlueskyAppview.ts
+26
-7
lib/hooks/useBlueskyAppview.ts
···
236
236
}: UseBlueskyAppviewOptions): UseBlueskyAppviewResult<T> {
237
237
const { recordCache, blueskyAppviewService, resolver } = useAtProto();
238
238
const effectiveAppviewService = appviewService ?? blueskyAppviewService;
239
+
240
+
// Only use this hook for Bluesky collections (app.bsky.*)
241
+
const isBlueskyCollection = collection?.startsWith("app.bsky.");
242
+
239
243
const {
240
244
did,
241
245
error: didError,
···
261
265
262
266
// Early returns for missing inputs or resolution errors
263
267
if (!handleOrDid || !collection || !rkey) {
268
+
if (!cancelled) dispatch({ type: "RESET" });
269
+
return () => {
270
+
cancelled = true;
271
+
if (releaseRef.current) {
272
+
releaseRef.current();
273
+
releaseRef.current = undefined;
274
+
}
275
+
};
276
+
}
277
+
278
+
// Return early if not a Bluesky collection - this hook should not be used for other lexicons
279
+
if (!isBlueskyCollection) {
264
280
if (!cancelled) dispatch({ type: "RESET" });
265
281
return () => {
266
282
cancelled = true;
···
683
699
};
684
700
}> {
685
701
const { rpc } = await createAtprotoClient({ service });
702
+
703
+
const params: Record<string, unknown> = {
704
+
repo: did,
705
+
collection,
706
+
limit,
707
+
cursor,
708
+
reverse: false,
709
+
};
710
+
686
711
return await (rpc as unknown as {
687
712
get: (
688
713
nsid: string,
···
695
720
};
696
721
}>;
697
722
}).get("com.atproto.repo.listRecords", {
698
-
params: {
699
-
repo: did,
700
-
collection,
701
-
limit,
702
-
cursor,
703
-
reverse: false,
704
-
},
723
+
params,
705
724
});
706
725
}
707
726
+5
-2
lib/hooks/useLatestRecord.ts
+5
-2
lib/hooks/useLatestRecord.ts
···
21
21
22
22
/**
23
23
* Fetches the most recent record from a collection using `listRecords(limit=3)`.
24
-
*
24
+
*
25
25
* Note: Slingshot does not support listRecords, so this always queries the actor's PDS directly.
26
-
*
26
+
*
27
27
* Records with invalid timestamps (before 2023, when ATProto was created) are automatically
28
28
* skipped, and additional records are fetched to find a valid one.
29
29
*
30
30
* @param handleOrDid - Handle or DID that owns the collection.
31
31
* @param collection - NSID of the collection to query.
32
+
* @param refreshKey - Optional key that when changed, triggers a refetch. Use for auto-refresh scenarios.
32
33
* @returns {LatestRecordState<T>} Object reporting the latest record value, derived rkey, loading status, emptiness, and any error.
33
34
*/
34
35
export function useLatestRecord<T = unknown>(
35
36
handleOrDid: string | undefined,
36
37
collection: string,
38
+
refreshKey?: number,
37
39
): LatestRecordState<T> {
38
40
const {
39
41
did,
···
157
159
resolvingEndpoint,
158
160
didError,
159
161
endpointError,
162
+
refreshKey,
160
163
]);
161
164
162
165
return state;
+74
-32
lib/renderers/CurrentlyPlayingRenderer.tsx
+74
-32
lib/renderers/CurrentlyPlayingRenderer.tsx
···
1
-
import React, { useState, useEffect } from "react";
1
+
import React, { useState, useEffect, useRef } from "react";
2
2
import type { TealActorStatusRecord } from "../types/teal";
3
3
4
4
export interface CurrentlyPlayingRendererProps {
···
8
8
did: string;
9
9
rkey: string;
10
10
colorScheme?: "light" | "dark" | "system";
11
-
autoRefresh?: boolean;
12
11
/** Label to display (e.g., "CURRENTLY PLAYING", "LAST PLAYED"). Defaults to "CURRENTLY PLAYING". */
13
12
label?: string;
14
-
/** Refresh interval in milliseconds. Defaults to 15000 (15 seconds). */
15
-
refreshInterval?: number;
16
13
/** Handle to display in not listening state */
17
14
handle?: string;
18
15
}
···
41
38
record,
42
39
error,
43
40
loading,
44
-
autoRefresh = true,
45
41
label = "CURRENTLY PLAYING",
46
-
refreshInterval = 15000,
47
42
handle,
48
43
}) => {
49
44
const [albumArt, setAlbumArt] = useState<string | undefined>(undefined);
50
45
const [artworkLoading, setArtworkLoading] = useState(true);
51
46
const [songlinkData, setSonglinkData] = useState<SonglinkResponse | undefined>(undefined);
52
47
const [showPlatformModal, setShowPlatformModal] = useState(false);
53
-
const [refreshKey, setRefreshKey] = useState(0);
48
+
const previousTrackIdentityRef = useRef<string>("");
54
49
55
-
// Auto-refresh interval
56
-
useEffect(() => {
57
-
if (!autoRefresh) return;
58
-
59
-
const interval = setInterval(() => {
60
-
// Reset loading state before refresh
61
-
setArtworkLoading(true);
62
-
setRefreshKey((prev) => prev + 1);
63
-
}, refreshInterval);
64
-
65
-
return () => clearInterval(interval);
66
-
}, [autoRefresh, refreshInterval]);
50
+
// Auto-refresh interval removed - handled by AtProtoRecord
67
51
68
52
useEffect(() => {
69
53
if (!record) return;
···
77
61
return;
78
62
}
79
63
80
-
// Reset loading state at start of fetch
81
-
if (refreshKey > 0) {
64
+
// Create a unique identity for this track
65
+
const trackIdentity = `${trackName}::${artistName}`;
66
+
67
+
// Check if the track has actually changed
68
+
const trackHasChanged = trackIdentity !== previousTrackIdentityRef.current;
69
+
70
+
// Update tracked identity
71
+
previousTrackIdentityRef.current = trackIdentity;
72
+
73
+
// Only reset loading state and clear data when track actually changes
74
+
// This prevents the loading flicker when auto-refreshing the same track
75
+
if (trackHasChanged) {
76
+
console.log(`[teal.fm] 🎵 Track changed: "${trackName}" by ${artistName}`);
82
77
setArtworkLoading(true);
78
+
setAlbumArt(undefined);
79
+
setSonglinkData(undefined);
80
+
} else {
81
+
console.log(`[teal.fm] 🔄 Auto-refresh: same track still playing ("${trackName}" by ${artistName})`);
83
82
}
84
83
85
84
let cancelled = false;
···
100
99
// Extract album art from Songlink data
101
100
const entityId = data.entityUniqueId;
102
101
const entity = data.entitiesByUniqueId?.[entityId];
102
+
103
+
// Debug: Log the entity structure to see what fields are available
104
+
console.log(`[teal.fm] ISRC entity data:`, { entityId, entity });
105
+
103
106
if (entity?.thumbnailUrl) {
104
107
console.log(`[teal.fm] ✓ Found album art via ISRC lookup`);
105
108
setAlbumArt(entity.thumbnailUrl);
106
109
} else {
107
-
console.warn(`[teal.fm] ISRC lookup succeeded but no thumbnail found`);
110
+
console.warn(`[teal.fm] ISRC lookup succeeded but no thumbnail found`, {
111
+
entityId,
112
+
entityKeys: entity ? Object.keys(entity) : 'no entity',
113
+
entity
114
+
});
108
115
}
109
116
setArtworkLoading(false);
110
117
return;
···
187
194
if (!albumArt) {
188
195
const entityId = data.entityUniqueId;
189
196
const entity = data.entitiesByUniqueId?.[entityId];
197
+
198
+
// Debug: Log the entity structure to see what fields are available
199
+
console.log(`[teal.fm] Songlink originUrl entity data:`, { entityId, entity });
200
+
190
201
if (entity?.thumbnailUrl) {
191
202
console.log(`[teal.fm] ✓ Found album art via Songlink originUrl lookup`);
192
203
setAlbumArt(entity.thumbnailUrl);
193
204
} else {
194
-
console.warn(`[teal.fm] Songlink lookup succeeded but no thumbnail found`);
205
+
console.warn(`[teal.fm] Songlink lookup succeeded but no thumbnail found`, {
206
+
entityId,
207
+
entityKeys: entity ? Object.keys(entity) : 'no entity',
208
+
entity
209
+
});
195
210
}
196
211
}
197
212
} else {
···
215
230
return () => {
216
231
cancelled = true;
217
232
};
218
-
}, [record, refreshKey]); // Add refreshKey to trigger refetch
233
+
}, [record]); // Runs on record change
219
234
220
235
if (error)
221
236
return (
···
266
281
267
282
const artistNames = item.artists.map((a) => a.artistName).join(", ");
268
283
269
-
const platformConfig: Record<string, { name: string; icon: string; color: string }> = {
270
-
spotify: { name: "Spotify", icon: "♫", color: "#1DB954" },
271
-
appleMusic: { name: "Apple Music", icon: "🎵", color: "#FA243C" },
272
-
youtube: { name: "YouTube", icon: "▶", color: "#FF0000" },
273
-
youtubeMusic: { name: "YouTube Music", icon: "▶", color: "#FF0000" },
274
-
tidal: { name: "Tidal", icon: "🌊", color: "#00FFFF" },
275
-
bandcamp: { name: "Bandcamp", icon: "△", color: "#1DA0C3" },
284
+
const platformConfig: Record<string, { name: string; svg: string; color: string }> = {
285
+
spotify: {
286
+
name: "Spotify",
287
+
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="#1ed760" d="M248 8C111.1 8 0 119.1 0 256s111.1 248 248 248 248-111.1 248-248S384.9 8 248 8Z"/><path d="M406.6 231.1c-5.2 0-8.4-1.3-12.9-3.9-71.2-42.5-198.5-52.7-280.9-29.7-3.6 1-8.1 2.6-12.9 2.6-13.2 0-23.3-10.3-23.3-23.6 0-13.6 8.4-21.3 17.4-23.9 35.2-10.3 74.6-15.2 117.5-15.2 73 0 149.5 15.2 205.4 47.8 7.8 4.5 12.9 10.7 12.9 22.6 0 13.6-11 23.3-23.2 23.3zm-31 76.2c-5.2 0-8.7-2.3-12.3-4.2-62.5-37-155.7-51.9-238.6-29.4-4.8 1.3-7.4 2.6-11.9 2.6-10.7 0-19.4-8.7-19.4-19.4s5.2-17.8 15.5-20.7c27.8-7.8 56.2-13.6 97.8-13.6 64.9 0 127.6 16.1 177 45.5 8.1 4.8 11.3 11 11.3 19.7-.1 10.8-8.5 19.5-19.4 19.5zm-26.9 65.6c-4.2 0-6.8-1.3-10.7-3.6-62.4-37.6-135-39.2-206.7-24.5-3.9 1-9 2.6-11.9 2.6-9.7 0-15.8-7.7-15.8-15.8 0-10.3 6.1-15.2 13.6-16.8 81.9-18.1 165.6-16.5 237 26.2 6.1 3.9 9.7 7.4 9.7 16.5s-7.1 15.4-15.2 15.4z"/></svg>',
288
+
color: "#1DB954"
289
+
},
290
+
appleMusic: {
291
+
name: "Apple Music",
292
+
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 361 361"><defs><linearGradient id="apple-grad" x1="180" y1="358.6" x2="180" y2="7.76" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#FA233B"/><stop offset="1" style="stop-color:#FB5C74"/></linearGradient></defs><path fill="url(#apple-grad)" d="M360 112.61V247.39c0 4.3 0 8.6-.02 12.9-.02 3.62-.06 7.24-.16 10.86-.21 7.89-.68 15.84-2.08 23.64-1.42 7.92-3.75 15.29-7.41 22.49-3.6 7.07-8.3 13.53-13.91 19.14-5.61 5.61-12.08 10.31-19.15 13.91-7.19 3.66-14.56 5.98-22.47 7.41-7.8 1.4-15.76 1.87-23.65 2.08-3.62.1-7.24.14-10.86.16-4.3.03-8.6.02-12.9.02H112.61c-4.3 0-8.6 0-12.9-.02-3.62-.02-7.24-.06-10.86-.16-7.89-.21-15.85-.68-23.65-2.08-7.92-1.42-15.28-3.75-22.47-7.41-7.07-3.6-13.54-8.3-19.15-13.91-5.61-5.61-10.31-12.07-13.91-19.14-3.66-7.2-5.99-14.57-7.41-22.49-1.4-7.8-1.87-15.76-2.08-23.64-.1-3.62-.14-7.24-.16-10.86C0 255.99 0 251.69 0 247.39V112.61c0-4.3 0-8.6.02-12.9.02-3.62.06-7.24.16-10.86.21-7.89.68-15.84 2.08-23.64 1.42-7.92 3.75-15.29 7.41-22.49 3.6-7.07 8.3-13.53 13.91-19.14 5.61-5.61 12.08-10.31 19.15-13.91 7.19-3.66 14.56-5.98 22.47-7.41 7.8-1.4 15.76-1.87 23.65-2.08 3.62-.1 7.24-.14 10.86-.16C104.01 0 108.31 0 112.61 0h134.77c4.3 0 8.6 0 12.9.02 3.62.02 7.24.06 10.86.16 7.89.21 15.85.68 23.65 2.08 7.92 1.42 15.28 3.75 22.47 7.41 7.07 3.6 13.54 8.3 19.15 13.91 5.61 5.61 10.31 12.07 13.91 19.14 3.66 7.2 5.99 14.57 7.41 22.49 1.4 7.8 1.87 15.76 2.08 23.64.1 3.62.14 7.24.16 10.86.03 4.3.02 8.6.02 12.9z"/><path fill="#FFF" d="M254.5 55c-.87.08-8.6 1.45-9.53 1.64l-107 21.59-.04.01c-2.79.59-4.98 1.58-6.67 3-2.04 1.71-3.17 4.13-3.6 6.95-.09.6-.24 1.82-.24 3.62v133.92c0 3.13-.25 6.17-2.37 8.76-2.12 2.59-4.74 3.37-7.81 3.99-2.33.47-4.66.94-6.99 1.41-8.84 1.78-14.59 2.99-19.8 5.01-4.98 1.93-8.71 4.39-11.68 7.51-5.89 6.17-8.28 14.54-7.46 22.38.7 6.69 3.71 13.09 8.88 17.82 3.49 3.2 7.85 5.63 12.99 6.66 5.33 1.07 11.01.7 19.31-.98 4.42-.89 8.56-2.28 12.5-4.61 3.9-2.3 7.24-5.37 9.85-9.11 2.62-3.75 4.31-7.92 5.24-12.35.96-4.57 1.19-8.7 1.19-13.26V128.82c0-6.22 1.76-7.86 6.78-9.08l93.09-18.75c5.79-1.11 8.52.54 8.52 6.61v79.29c0 3.14-.03 6.32-2.17 8.92-2.12 2.59-4.74 3.37-7.81 3.99-2.33.47-4.66.94-6.99 1.41-8.84 1.78-14.59 2.99-19.8 5.01-4.98 1.93-8.71 4.39-11.68 7.51-5.89 6.17-8.49 14.54-7.67 22.38.7 6.69 3.92 13.09 9.09 17.82 3.49 3.2 7.85 5.56 12.99 6.6 5.33 1.07 11.01.69 19.31-.98 4.42-.89 8.56-2.22 12.5-4.55 3.9-2.3 7.24-5.37 9.85-9.11 2.62-3.75 4.31-7.92 5.24-12.35.96-4.57 1-8.7 1-13.26V64.46c0-6.16-3.25-9.96-9.04-9.46z"/></svg>',
293
+
color: "#FA243C"
294
+
},
295
+
youtube: {
296
+
name: "YouTube",
297
+
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><g transform="scale(.75)"><path fill="red" d="M199.917 105.63s-84.292 0-105.448 5.497c-11.328 3.165-20.655 12.493-23.82 23.987-5.498 21.156-5.498 64.969-5.498 64.969s0 43.979 5.497 64.802c3.165 11.494 12.326 20.655 23.82 23.82 21.323 5.664 105.448 5.664 105.448 5.664s84.459 0 105.615-5.497c11.494-3.165 20.655-12.16 23.654-23.82 5.664-20.99 5.664-64.803 5.664-64.803s.166-43.98-5.664-65.135c-2.999-11.494-12.16-20.655-23.654-23.654-21.156-5.83-105.615-5.83-105.615-5.83zm-26.82 53.974 70.133 40.479-70.133 40.312v-80.79z"/><path fill="#fff" d="m173.097 159.604 70.133 40.479-70.133 40.312v-80.79z"/></g></svg>',
298
+
color: "#FF0000"
299
+
},
300
+
youtubeMusic: {
301
+
name: "YouTube Music",
302
+
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 176 176"><circle fill="#FF0000" cx="88" cy="88" r="88"/><path fill="#FFF" d="M88 46c23.1 0 42 18.8 42 42s-18.8 42-42 42-42-18.8-42-42 18.8-42 42-42m0-4c-25.4 0-46 20.6-46 46s20.6 46 46 46 46-20.6 46-46-20.6-46-46-46z"/><path fill="#FFF" d="m72 111 39-24-39-22z"/></svg>',
303
+
color: "#FF0000"
304
+
},
305
+
tidal: {
306
+
name: "Tidal",
307
+
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 0c141.385 0 256 114.615 256 256S397.385 512 256 512 0 397.385 0 256 114.615 0 256 0zm50.384 219.459-50.372 50.383 50.379 50.391-50.382 50.393-50.395-50.393 50.393-50.389-50.393-50.39 50.395-50.372 50.38 50.369 50.389-50.375 50.382 50.382-50.382 50.392-50.394-50.391zm-100.767-.001-50.392 50.392-50.385-50.392 50.385-50.382 50.392 50.382z"/></svg>',
308
+
color: "#000000"
309
+
},
310
+
bandcamp: {
311
+
name: "Bandcamp",
312
+
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#1DA0C3" d="M0 156v200h172l84-200z"/></svg>',
313
+
color: "#1DA0C3"
314
+
},
276
315
};
277
316
278
317
const availablePlatforms = songlinkData
···
420
459
onClick={() => setShowPlatformModal(false)}
421
460
data-teal-platform="true"
422
461
>
423
-
<span style={styles.platformIcon}>{config.icon}</span>
462
+
<span
463
+
style={styles.platformIcon}
464
+
dangerouslySetInnerHTML={{ __html: config.svg }}
465
+
/>
424
466
<span style={styles.platformName}>{config.name}</span>
425
467
<svg
426
468
width="20"
+13
lib/utils/cache.ts
+13
lib/utils/cache.ts
···
290
290
export class RecordCache {
291
291
private store = new Map<string, RecordCacheEntry>();
292
292
private inFlight = new Map<string, InFlightRecordEntry>();
293
+
// Collections that should not be cached (e.g., status records that change frequently)
294
+
private noCacheCollections = new Set<string>([
295
+
"fm.teal.alpha.actor.status",
296
+
"fm.teal.alpha.feed.play",
297
+
]);
293
298
294
299
private key(did: string, collection: string, rkey: string): string {
295
300
return `${did}::${collection}::${rkey}`;
296
301
}
297
302
303
+
private shouldCache(collection: string): boolean {
304
+
return !this.noCacheCollections.has(collection);
305
+
}
306
+
298
307
get<T = unknown>(
299
308
did?: string,
300
309
collection?: string,
301
310
rkey?: string,
302
311
): T | undefined {
303
312
if (!did || !collection || !rkey) return undefined;
313
+
// Don't return cached data for non-cacheable collections
314
+
if (!this.shouldCache(collection)) return undefined;
304
315
return this.store.get(this.key(did, collection, rkey))?.record as
305
316
| T
306
317
| undefined;
···
312
323
rkey: string,
313
324
record: T,
314
325
): void {
326
+
// Don't cache records for non-cacheable collections
327
+
if (!this.shouldCache(collection)) return;
315
328
this.store.set(this.key(did, collection, rkey), {
316
329
record,
317
330
timestamp: Date.now(),