+701
CLAUDE.md
+701
CLAUDE.md
···
1
+
# AtReact Hooks Deep Dive
2
+
3
+
## Overview
4
+
The AtReact hooks system provides a robust, cache-optimized layer for fetching AT Protocol data. All hooks follow React best practices with proper cleanup, cancellation, and stable references.
5
+
6
+
---
7
+
8
+
## Core Architecture Principles
9
+
10
+
### 1. **Three-Tier Caching Strategy**
11
+
All data flows through three cache layers:
12
+
- **DidCache** - DID documents, handle mappings, PDS endpoints
13
+
- **BlobCache** - Media/image blobs with reference counting
14
+
- **RecordCache** - AT Protocol records with deduplication
15
+
16
+
### 2. **Concurrent Request Deduplication**
17
+
When multiple components request the same data, only one network request is made. Uses reference counting to manage in-flight requests.
18
+
19
+
### 3. **Stable Reference Pattern**
20
+
Caches use memoized snapshots to prevent unnecessary re-renders:
21
+
```typescript
22
+
// Only creates new snapshot if data actually changed
23
+
if (existing && existing.did === did && existing.handle === handle) {
24
+
return toSnapshot(existing); // Reuse existing
25
+
}
26
+
```
27
+
28
+
### 4. **Three-Tier Fallback for Bluesky**
29
+
For `app.bsky.*` collections:
30
+
1. Try Bluesky appview API (fastest, public)
31
+
2. Fall back to Slingshot (microcosm service)
32
+
3. Finally query PDS directly
33
+
34
+
---
35
+
36
+
## Hook Catalog
37
+
38
+
## 1. `useDidResolution`
39
+
**Purpose:** Resolves handles to DIDs or fetches DID documents
40
+
41
+
### Key Features:
42
+
- **Bidirectional:** Works with handles OR DIDs
43
+
- **Smart Caching:** Only fetches if not in cache
44
+
- **Dual Resolution Paths:**
45
+
- Handle → DID: Uses Slingshot first, then appview
46
+
- DID → Document: Fetches full DID document for handle extraction
47
+
48
+
### State Flow:
49
+
```typescript
50
+
Input: "alice.bsky.social" or "did:plc:xxx"
51
+
↓
52
+
Check didCache
53
+
↓
54
+
If handle: ensureHandle(resolver, handle) → DID
55
+
If DID: ensureDidDoc(resolver, did) → DID doc + handle from alsoKnownAs
56
+
↓
57
+
Return: { did, handle, loading, error }
58
+
```
59
+
60
+
### Critical Implementation Details:
61
+
- **Normalizes input** to lowercase for handles
62
+
- **Memoizes input** to prevent effect re-runs
63
+
- **Stabilizes error references** - only updates if message changes
64
+
- **Cleanup:** Cancellation token prevents stale updates
65
+
66
+
---
67
+
68
+
## 2. `usePdsEndpoint`
69
+
**Purpose:** Discovers the PDS endpoint for a DID
70
+
71
+
### Key Features:
72
+
- **Depends on DID resolution** (implicit dependency)
73
+
- **Extracts from DID document** if already cached
74
+
- **Lazy fetching** - only when endpoint not in cache
75
+
76
+
### State Flow:
77
+
```typescript
78
+
Input: DID
79
+
↓
80
+
Check didCache.getByDid(did).pdsEndpoint
81
+
↓
82
+
If missing: ensurePdsEndpoint(resolver, did)
83
+
├─ Tries to get from existing DID doc
84
+
└─ Falls back to resolver.pdsEndpointForDid()
85
+
↓
86
+
Return: { endpoint, loading, error }
87
+
```
88
+
89
+
### Service Discovery:
90
+
Looks for `AtprotoPersonalDataServer` service in DID document:
91
+
```json
92
+
{
93
+
"service": [{
94
+
"type": "AtprotoPersonalDataServer",
95
+
"serviceEndpoint": "https://pds.example.com"
96
+
}]
97
+
}
98
+
```
99
+
100
+
---
101
+
102
+
## 3. `useAtProtoRecord`
103
+
**Purpose:** Fetches a single AT Protocol record with smart routing
104
+
105
+
### Key Features:
106
+
- **Collection-aware routing:** Bluesky vs other protocols
107
+
- **RecordCache deduplication:** Multiple components = one fetch
108
+
- **Cleanup with reference counting**
109
+
110
+
### State Flow:
111
+
```typescript
112
+
Input: { did, collection, rkey }
113
+
↓
114
+
If collection.startsWith("app.bsky."):
115
+
└─ useBlueskyAppview() → Three-tier fallback
116
+
Else:
117
+
├─ useDidResolution(did)
118
+
├─ usePdsEndpoint(resolved.did)
119
+
└─ recordCache.ensure() → Fetch from PDS
120
+
↓
121
+
Return: { record, loading, error }
122
+
```
123
+
124
+
### RecordCache Deduplication:
125
+
```typescript
126
+
// First component calling this
127
+
const { promise, release } = recordCache.ensure(did, collection, rkey, loader)
128
+
// refCount = 1
129
+
130
+
// Second component calling same record
131
+
const { promise, release } = recordCache.ensure(...) // Same promise!
132
+
// refCount = 2
133
+
134
+
// On cleanup, both call release()
135
+
// Only aborts when refCount reaches 0
136
+
```
137
+
138
+
---
139
+
140
+
## 4. `useBlueskyAppview`
141
+
**Purpose:** Fetches Bluesky records with appview optimization
142
+
143
+
### Key Features:
144
+
- **Collection-aware endpoints:**
145
+
- `app.bsky.actor.profile` → `app.bsky.actor.getProfile`
146
+
- `app.bsky.feed.post` → `app.bsky.feed.getPostThread`
147
+
- **CDN URL extraction:** Parses CDN URLs to extract CIDs
148
+
- **Atomic state updates:** Uses reducer for complex state
149
+
150
+
### Three-Tier Fallback with Source Tracking:
151
+
```typescript
152
+
async function fetchWithFallback() {
153
+
// Tier 1: Appview (if endpoint mapped)
154
+
try {
155
+
const result = await fetchFromAppview(did, collection, rkey);
156
+
return { record: result, source: "appview" };
157
+
} catch {}
158
+
159
+
// Tier 2: Slingshot
160
+
try {
161
+
const result = await fetchFromSlingshot(did, collection, rkey);
162
+
return { record: result, source: "slingshot" };
163
+
} catch {}
164
+
165
+
// Tier 3: PDS
166
+
try {
167
+
const result = await fetchFromPds(did, collection, rkey);
168
+
return { record: result, source: "pds" };
169
+
} catch {}
170
+
171
+
// All tiers failed - provide helpful error for banned Bluesky accounts
172
+
if (pdsEndpoint.includes('.bsky.network')) {
173
+
throw new Error('Record unavailable. The Bluesky PDS may be unreachable or the account may be banned.');
174
+
}
175
+
176
+
throw new Error('Failed to fetch record from all sources');
177
+
}
178
+
```
179
+
180
+
The `source` field in the result accurately indicates which tier successfully fetched the data, enabling debugging and analytics.
181
+
182
+
### CDN URL Handling:
183
+
Appview returns CDN URLs like:
184
+
```
185
+
https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg
186
+
```
187
+
188
+
Hook extracts CID (`bafkreixxx`) and creates standard Blob object:
189
+
```typescript
190
+
{
191
+
$type: "blob",
192
+
ref: { $link: "bafkreixxx" },
193
+
mimeType: "image/jpeg",
194
+
size: 0,
195
+
cdnUrl: "https://cdn.bsky.app/..." // Preserved for fast rendering
196
+
}
197
+
```
198
+
199
+
### Reducer Pattern:
200
+
```typescript
201
+
type Action =
202
+
| { type: "SET_LOADING"; loading: boolean }
203
+
| { type: "SET_SUCCESS"; record: T; source: "appview" | "slingshot" | "pds" }
204
+
| { type: "SET_ERROR"; error: Error }
205
+
| { type: "RESET" };
206
+
207
+
// Atomic state updates, no race conditions
208
+
dispatch({ type: "SET_SUCCESS", record, source });
209
+
```
210
+
211
+
---
212
+
213
+
## 5. `useLatestRecord`
214
+
**Purpose:** Fetches the most recent record from a collection
215
+
216
+
### Key Features:
217
+
- **Timestamp validation:** Skips records before 2023 (pre-ATProto)
218
+
- **PDS-only:** Slingshot doesn't support `listRecords`
219
+
- **Smart fetching:** Gets 3 records to handle invalid timestamps
220
+
221
+
### State Flow:
222
+
```typescript
223
+
Input: { did, collection }
224
+
↓
225
+
useDidResolution(did)
226
+
usePdsEndpoint(did)
227
+
↓
228
+
callListRecords(endpoint, did, collection, limit: 3)
229
+
↓
230
+
Filter: isValidTimestamp(record) → year >= 2023
231
+
↓
232
+
Return first valid record: { record, rkey, loading, error, empty }
233
+
```
234
+
235
+
### Timestamp Validation:
236
+
```typescript
237
+
function isValidTimestamp(record: unknown): boolean {
238
+
const timestamp = record.createdAt || record.indexedAt;
239
+
if (!timestamp) return true; // No timestamp, assume valid
240
+
241
+
const date = new Date(timestamp);
242
+
return date.getFullYear() >= 2023; // ATProto created in 2023
243
+
}
244
+
```
245
+
246
+
---
247
+
248
+
## 6. `usePaginatedRecords`
249
+
**Purpose:** Cursor-based pagination with prefetching
250
+
251
+
### Key Features:
252
+
- **Dual fetching modes:**
253
+
- Author feed (appview) - for Bluesky posts with filters
254
+
- Direct PDS - for all other collections
255
+
- **Smart prefetching:** Loads next page in background
256
+
- **Invalid timestamp filtering:** Same as `useLatestRecord`
257
+
- **Request sequencing:** Prevents race conditions with `requestSeq`
258
+
259
+
### State Management:
260
+
```typescript
261
+
// Pages stored as array
262
+
pages: [
263
+
{ records: [...], cursor: "abc" }, // page 0
264
+
{ records: [...], cursor: "def" }, // page 1
265
+
{ records: [...], cursor: undefined } // page 2 (last)
266
+
]
267
+
pageIndex: 1 // Currently viewing page 1
268
+
```
269
+
270
+
### Prefetch Logic:
271
+
```typescript
272
+
useEffect(() => {
273
+
const cursor = pages[pageIndex]?.cursor;
274
+
if (!cursor || pages[pageIndex + 1]) return; // No cursor or already loaded
275
+
276
+
// Prefetch next page in background
277
+
fetchPage(identity, cursor, pageIndex + 1, "prefetch");
278
+
}, [pageIndex, pages]);
279
+
```
280
+
281
+
### Author Feed vs PDS:
282
+
```typescript
283
+
if (preferAuthorFeed && collection === "app.bsky.feed.post") {
284
+
// Use app.bsky.feed.getAuthorFeed
285
+
const res = await callAppviewRpc("app.bsky.feed.getAuthorFeed", {
286
+
actor: handle || did,
287
+
filter: "posts_with_media", // Optional filter
288
+
includePins: true
289
+
});
290
+
} else {
291
+
// Use com.atproto.repo.listRecords
292
+
const res = await callListRecords(pdsEndpoint, did, collection, limit);
293
+
}
294
+
```
295
+
296
+
### Race Condition Prevention:
297
+
```typescript
298
+
const requestSeq = useRef(0);
299
+
300
+
// On identity change
301
+
resetState();
302
+
requestSeq.current += 1; // Invalidate in-flight requests
303
+
304
+
// In fetch callback
305
+
const token = requestSeq.current;
306
+
// ... do async work ...
307
+
if (token !== requestSeq.current) return; // Stale request, abort
308
+
```
309
+
310
+
---
311
+
312
+
## 7. `useBlob`
313
+
**Purpose:** Fetches and caches media blobs with object URL management
314
+
315
+
### Key Features:
316
+
- **Automatic cleanup:** Revokes object URLs on unmount
317
+
- **BlobCache deduplication:** Same blob = one fetch
318
+
- **Reference counting:** Safe concurrent access
319
+
320
+
### State Flow:
321
+
```typescript
322
+
Input: { did, cid }
323
+
↓
324
+
useDidResolution(did)
325
+
usePdsEndpoint(did)
326
+
↓
327
+
Check blobCache.get(did, cid)
328
+
↓
329
+
If missing: blobCache.ensure() → Fetch from PDS
330
+
├─ GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}
331
+
└─ Store in cache
332
+
↓
333
+
Create object URL: URL.createObjectURL(blob)
334
+
↓
335
+
Return: { url, loading, error }
336
+
↓
337
+
Cleanup: URL.revokeObjectURL(url)
338
+
```
339
+
340
+
### Object URL Management:
341
+
```typescript
342
+
const objectUrlRef = useRef<string>();
343
+
344
+
// On successful fetch
345
+
const nextUrl = URL.createObjectURL(blob);
346
+
const prevUrl = objectUrlRef.current;
347
+
objectUrlRef.current = nextUrl;
348
+
if (prevUrl) URL.revokeObjectURL(prevUrl); // Clean up old URL
349
+
350
+
// On unmount
351
+
useEffect(() => () => {
352
+
if (objectUrlRef.current) {
353
+
URL.revokeObjectURL(objectUrlRef.current);
354
+
}
355
+
}, []);
356
+
```
357
+
358
+
---
359
+
360
+
## 8. `useBlueskyProfile`
361
+
**Purpose:** Wrapper around `useBlueskyAppview` for profile records
362
+
363
+
### Key Features:
364
+
- **Simplified interface:** Just pass DID
365
+
- **Type conversion:** Converts ProfileRecord to BlueskyProfileData
366
+
- **CID extraction:** Extracts avatar/banner CIDs from blobs
367
+
368
+
### Implementation:
369
+
```typescript
370
+
export function useBlueskyProfile(did: string | undefined) {
371
+
const { record, loading, error } = useBlueskyAppview<ProfileRecord>({
372
+
did,
373
+
collection: "app.bsky.actor.profile",
374
+
rkey: "self",
375
+
});
376
+
377
+
const data = record ? {
378
+
did: did || "",
379
+
handle: "", // Populated by caller
380
+
displayName: record.displayName,
381
+
description: record.description,
382
+
avatar: extractCidFromBlob(record.avatar),
383
+
banner: extractCidFromBlob(record.banner),
384
+
createdAt: record.createdAt,
385
+
} : undefined;
386
+
387
+
return { data, loading, error };
388
+
}
389
+
```
390
+
391
+
---
392
+
393
+
## 9. `useBacklinks`
394
+
**Purpose:** Fetches backlinks from Microcosm Constellation API
395
+
396
+
### Key Features:
397
+
- **Specialized use case:** Tangled stars, etc.
398
+
- **Abort controller:** Cancels in-flight requests
399
+
- **Refetch support:** Manual refresh capability
400
+
401
+
### State Flow:
402
+
```typescript
403
+
Input: { subject: "at://did:plc:xxx/sh.tangled.repo/yyy", source: "sh.tangled.feed.star:subject" }
404
+
↓
405
+
GET https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks
406
+
?subject={subject}&source={source}&limit={limit}
407
+
↓
408
+
Return: { backlinks: [...], total, loading, error, refetch }
409
+
```
410
+
411
+
---
412
+
413
+
## 10. `useRepoLanguages`
414
+
**Purpose:** Fetches language statistics from Tangled knot server
415
+
416
+
### Key Features:
417
+
- **Branch fallback:** Tries "main", then "master"
418
+
- **Knot server query:** For repository analysis
419
+
420
+
### State Flow:
421
+
```typescript
422
+
Input: { knot: "knot.gaze.systems", did, repoName, branch }
423
+
↓
424
+
GET https://{knot}/xrpc/sh.tangled.repo.languages
425
+
?repo={did}/{repoName}&ref={branch}
426
+
↓
427
+
If 404: Try fallback branch
428
+
↓
429
+
Return: { data: { languages: {...} }, loading, error }
430
+
```
431
+
432
+
---
433
+
434
+
## Cache Implementation Deep Dive
435
+
436
+
### DidCache
437
+
**Purpose:** Cache DID documents, handle mappings, PDS endpoints
438
+
439
+
```typescript
440
+
class DidCache {
441
+
private byHandle = new Map<string, DidCacheEntry>();
442
+
private byDid = new Map<string, DidCacheEntry>();
443
+
private handlePromises = new Map<string, Promise<...>>();
444
+
private docPromises = new Map<string, Promise<...>>();
445
+
private pdsPromises = new Map<string, Promise<...>>();
446
+
447
+
// Memoized snapshots prevent re-renders
448
+
private toSnapshot(entry): DidCacheSnapshot {
449
+
if (entry.snapshot) return entry.snapshot; // Reuse
450
+
entry.snapshot = { did, handle, doc, pdsEndpoint };
451
+
return entry.snapshot;
452
+
}
453
+
}
454
+
```
455
+
456
+
**Key methods:**
457
+
- `getByHandle(handle)` - Instant cache lookup
458
+
- `getByDid(did)` - Instant cache lookup
459
+
- `ensureHandle(resolver, handle)` - Deduplicated resolution
460
+
- `ensureDidDoc(resolver, did)` - Deduplicated doc fetch
461
+
- `ensurePdsEndpoint(resolver, did)` - Deduplicated PDS discovery
462
+
463
+
**Snapshot stability:**
464
+
```typescript
465
+
memoize(entry) {
466
+
const existing = this.byDid.get(did);
467
+
468
+
// Data unchanged? Reuse snapshot (same reference)
469
+
if (existing && existing.did === did &&
470
+
existing.handle === handle && ...) {
471
+
return toSnapshot(existing); // Prevents re-render!
472
+
}
473
+
474
+
// Data changed, create new entry
475
+
const merged = { did, handle, doc, pdsEndpoint, snapshot: undefined };
476
+
this.byDid.set(did, merged);
477
+
return toSnapshot(merged);
478
+
}
479
+
```
480
+
481
+
### BlobCache
482
+
**Purpose:** Cache media blobs with reference counting
483
+
484
+
```typescript
485
+
class BlobCache {
486
+
private store = new Map<string, BlobCacheEntry>();
487
+
private inFlight = new Map<string, InFlightBlobEntry>();
488
+
489
+
ensure(did, cid, loader) {
490
+
// Already cached?
491
+
const cached = this.get(did, cid);
492
+
if (cached) return { promise: Promise.resolve(cached), release: noop };
493
+
494
+
// In-flight request?
495
+
const existing = this.inFlight.get(key);
496
+
if (existing) {
497
+
existing.refCount++; // Multiple consumers
498
+
return { promise: existing.promise, release: () => this.release(key) };
499
+
}
500
+
501
+
// New request
502
+
const { promise, abort } = loader();
503
+
this.inFlight.set(key, { promise, abort, refCount: 1 });
504
+
return { promise, release: () => this.release(key) };
505
+
}
506
+
507
+
private release(key) {
508
+
const entry = this.inFlight.get(key);
509
+
entry.refCount--;
510
+
if (entry.refCount <= 0) {
511
+
this.inFlight.delete(key);
512
+
entry.abort(); // Cancel fetch
513
+
}
514
+
}
515
+
}
516
+
```
517
+
518
+
### RecordCache
519
+
**Purpose:** Cache AT Protocol records with deduplication
520
+
521
+
Identical structure to BlobCache but for record data.
522
+
523
+
---
524
+
525
+
## Common Patterns
526
+
527
+
### 1. Cancellation Pattern
528
+
```typescript
529
+
useEffect(() => {
530
+
let cancelled = false;
531
+
532
+
const assignState = (next) => {
533
+
if (cancelled) return; // Don't update unmounted component
534
+
setState(prev => ({ ...prev, ...next }));
535
+
};
536
+
537
+
// ... async work ...
538
+
539
+
return () => {
540
+
cancelled = true; // Mark as cancelled
541
+
release?.(); // Decrement refCount
542
+
};
543
+
}, [deps]);
544
+
```
545
+
546
+
### 2. Error Stabilization Pattern
547
+
```typescript
548
+
setError(prevError =>
549
+
prevError?.message === newError.message
550
+
? prevError // Reuse same reference
551
+
: newError // New error
552
+
);
553
+
```
554
+
555
+
### 3. Identity Tracking Pattern
556
+
```typescript
557
+
const identityRef = useRef<string>();
558
+
const identity = did && endpoint ? `${did}::${endpoint}` : undefined;
559
+
560
+
useEffect(() => {
561
+
if (identityRef.current !== identity) {
562
+
identityRef.current = identity;
563
+
resetState(); // Clear stale data
564
+
}
565
+
// ...
566
+
}, [identity]);
567
+
```
568
+
569
+
### 4. Dual-Mode Resolution
570
+
```typescript
571
+
const isDid = input.startsWith("did:");
572
+
const normalizedHandle = !isDid ? input.toLowerCase() : undefined;
573
+
574
+
// Different code paths
575
+
if (isDid) {
576
+
snapshot = await didCache.ensureDidDoc(resolver, input);
577
+
} else {
578
+
snapshot = await didCache.ensureHandle(resolver, normalizedHandle);
579
+
}
580
+
```
581
+
582
+
---
583
+
584
+
## Performance Optimizations
585
+
586
+
### 1. **Memoized Snapshots**
587
+
Caches return stable references when data unchanged → prevents re-renders
588
+
589
+
### 2. **Reference Counting**
590
+
Multiple components requesting same data share one fetch
591
+
592
+
### 3. **Prefetching**
593
+
`usePaginatedRecords` loads next page in background
594
+
595
+
### 4. **CDN URLs**
596
+
Bluesky appview returns CDN URLs → skip blob fetching for images
597
+
598
+
### 5. **Smart Routing**
599
+
Bluesky collections use fast appview → non-Bluesky goes direct to PDS
600
+
601
+
### 6. **Request Deduplication**
602
+
In-flight request maps prevent duplicate fetches
603
+
604
+
### 7. **Timestamp Validation**
605
+
Skip invalid records early (before 2023) → fewer wasted cycles
606
+
607
+
---
608
+
609
+
## Error Handling Strategy
610
+
611
+
### 1. **Fallback Chains**
612
+
Never fail on first attempt → try multiple sources
613
+
614
+
### 2. **Graceful Degradation**
615
+
```typescript
616
+
// Slingshot failed? Try appview
617
+
try {
618
+
return await fetchFromSlingshot();
619
+
} catch (slingshotError) {
620
+
try {
621
+
return await fetchFromAppview();
622
+
} catch (appviewError) {
623
+
// Combine errors for better debugging
624
+
throw new Error(`${appviewError.message}; Slingshot: ${slingshotError.message}`);
625
+
}
626
+
}
627
+
```
628
+
629
+
### 3. **Component Isolation**
630
+
Errors in one component don't crash others (via error boundaries recommended)
631
+
632
+
### 4. **Abort Handling**
633
+
```typescript
634
+
try {
635
+
await fetch(url, { signal });
636
+
} catch (err) {
637
+
if (err.name === "AbortError") return; // Expected, ignore
638
+
throw err;
639
+
}
640
+
```
641
+
642
+
### 5. **Banned Bluesky Account Detection**
643
+
When all three tiers fail and the PDS is a `.bsky.network` endpoint, provide a helpful error:
644
+
```typescript
645
+
// All tiers failed - check if it's a banned Bluesky account
646
+
if (pdsEndpoint.includes('.bsky.network')) {
647
+
throw new Error(
648
+
'Record unavailable. The Bluesky PDS may be unreachable or the account may be banned.'
649
+
);
650
+
}
651
+
```
652
+
653
+
This helps users understand why data is unavailable instead of showing generic fetch errors. Applies to both `useBlueskyAppview` and `useAtProtoRecord` hooks.
654
+
655
+
---
656
+
657
+
## Testing Considerations
658
+
659
+
### Key scenarios to test:
660
+
1. **Concurrent requests:** Multiple components requesting same data
661
+
2. **Race conditions:** Component unmounting mid-fetch
662
+
3. **Cache invalidation:** Identity changes during fetch
663
+
4. **Error fallbacks:** Slingshot down → appview works
664
+
5. **Timestamp filtering:** Records before 2023 skipped
665
+
6. **Reference counting:** Proper cleanup on unmount
666
+
7. **Prefetching:** Background loads don't interfere with active loads
667
+
668
+
---
669
+
670
+
## Common Gotchas
671
+
672
+
### 1. **React Rules of Hooks**
673
+
All hooks called unconditionally, even if results not used:
674
+
```typescript
675
+
// Always call, conditionally use results
676
+
const blueskyResult = useBlueskyAppview({
677
+
did: isBlueskyCollection ? handleOrDid : undefined, // Pass undefined to skip
678
+
collection: isBlueskyCollection ? collection : undefined,
679
+
rkey: isBlueskyCollection ? rkey : undefined,
680
+
});
681
+
```
682
+
683
+
### 2. **Cleanup Order Matters**
684
+
```typescript
685
+
return () => {
686
+
cancelled = true; // 1. Prevent state updates
687
+
release?.(); // 2. Decrement refCount
688
+
revokeObjectURL(...); // 3. Free resources
689
+
};
690
+
```
691
+
692
+
### 3. **Snapshot Reuse**
693
+
Don't modify cached snapshots! They're shared across components.
694
+
695
+
### 4. **CDN URL Extraction**
696
+
Bluesky CDN URLs must be parsed carefully:
697
+
```
698
+
https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg
699
+
^^^^^^^^^^^^ ^^^^^^
700
+
DID CID
701
+
```
+125
lib/components/CurrentlyPlaying.tsx
+125
lib/components/CurrentlyPlaying.tsx
···
1
+
import React from "react";
2
+
import { AtProtoRecord } from "../core/AtProtoRecord";
3
+
import { CurrentlyPlayingRenderer } from "../renderers/CurrentlyPlayingRenderer";
4
+
import { useDidResolution } from "../hooks/useDidResolution";
5
+
import type { TealActorStatusRecord } from "../types/teal";
6
+
7
+
/**
8
+
* Props for rendering teal.fm currently playing status.
9
+
*/
10
+
export 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 every 15 seconds. Defaults to true. */
26
+
autoRefresh?: boolean;
27
+
}
28
+
29
+
/**
30
+
* Values injected into custom currently playing renderer implementations.
31
+
*/
32
+
export type CurrentlyPlayingRendererInjectedProps = {
33
+
/** Loaded teal.fm status record value. */
34
+
record: TealActorStatusRecord;
35
+
/** Indicates whether the record is currently loading. */
36
+
loading: boolean;
37
+
/** Fetch error, if any. */
38
+
error?: Error;
39
+
/** Preferred color scheme for downstream components. */
40
+
colorScheme?: "light" | "dark" | "system";
41
+
/** DID associated with the record. */
42
+
did: string;
43
+
/** Record key for the status. */
44
+
rkey: string;
45
+
/** Auto-refresh music data and album art every 15 seconds. */
46
+
autoRefresh?: boolean;
47
+
/** Label to display. */
48
+
label?: string;
49
+
/** Refresh interval in milliseconds. */
50
+
refreshInterval?: number;
51
+
/** Handle to display in not listening state */
52
+
handle?: string;
53
+
};
54
+
55
+
/** NSID for teal.fm actor status records. */
56
+
export const CURRENTLY_PLAYING_COLLECTION = "fm.teal.alpha.actor.status";
57
+
58
+
/**
59
+
* Displays the currently playing track from teal.fm with auto-refresh.
60
+
*
61
+
* @param did - DID whose currently playing status should be fetched.
62
+
* @param rkey - Record key within the teal.fm status collection (defaults to "self").
63
+
* @param renderer - Optional component override that will receive injected props.
64
+
* @param fallback - Node rendered before the first load begins.
65
+
* @param loadingIndicator - Node rendered while the status is loading.
66
+
* @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.
68
+
* @returns A JSX subtree representing the currently playing track with loading states handled.
69
+
*/
70
+
export const CurrentlyPlaying: React.FC<CurrentlyPlayingProps> = React.memo(({
71
+
did,
72
+
rkey = "self",
73
+
record,
74
+
renderer,
75
+
fallback,
76
+
loadingIndicator,
77
+
colorScheme,
78
+
autoRefresh = true,
79
+
}) => {
80
+
// Resolve handle from DID
81
+
const { handle } = useDidResolution(did);
82
+
83
+
const Comp: React.ComponentType<CurrentlyPlayingRendererInjectedProps> =
84
+
renderer ?? ((props) => <CurrentlyPlayingRenderer {...props} />);
85
+
const Wrapped: React.FC<{
86
+
record: TealActorStatusRecord;
87
+
loading: boolean;
88
+
error?: Error;
89
+
}> = (props) => (
90
+
<Comp
91
+
{...props}
92
+
colorScheme={colorScheme}
93
+
did={did}
94
+
rkey={rkey}
95
+
autoRefresh={autoRefresh}
96
+
label="CURRENTLY PLAYING"
97
+
refreshInterval={15000}
98
+
handle={handle}
99
+
/>
100
+
);
101
+
102
+
if (record !== undefined) {
103
+
return (
104
+
<AtProtoRecord<TealActorStatusRecord>
105
+
record={record}
106
+
renderer={Wrapped}
107
+
fallback={fallback}
108
+
loadingIndicator={loadingIndicator}
109
+
/>
110
+
);
111
+
}
112
+
113
+
return (
114
+
<AtProtoRecord<TealActorStatusRecord>
115
+
did={did}
116
+
collection={CURRENTLY_PLAYING_COLLECTION}
117
+
rkey={rkey}
118
+
renderer={Wrapped}
119
+
fallback={fallback}
120
+
loadingIndicator={loadingIndicator}
121
+
/>
122
+
);
123
+
});
124
+
125
+
export default CurrentlyPlaying;
+156
lib/components/LastPlayed.tsx
+156
lib/components/LastPlayed.tsx
···
1
+
import React, { useMemo } from "react";
2
+
import { useLatestRecord } from "../hooks/useLatestRecord";
3
+
import { useDidResolution } from "../hooks/useDidResolution";
4
+
import { CurrentlyPlayingRenderer } from "../renderers/CurrentlyPlayingRenderer";
5
+
import type { TealFeedPlayRecord } from "../types/teal";
6
+
7
+
/**
8
+
* Props for rendering the last played track from teal.fm feed.
9
+
*/
10
+
export 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
+
*/
30
+
export 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. */
52
+
export 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
+
*/
66
+
export 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
+
156
+
export default LastPlayed;
+30
-20
lib/hooks/useAtProtoRecord.ts
+30
-20
lib/hooks/useAtProtoRecord.ts
···
142
142
const controller = new AbortController();
143
143
144
144
const fetchPromise = (async () => {
145
-
const { rpc } = await createAtprotoClient({
146
-
service: endpoint,
147
-
});
148
-
const res = await (
149
-
rpc as unknown as {
150
-
get: (
151
-
nsid: string,
152
-
opts: {
153
-
params: {
154
-
repo: string;
155
-
collection: string;
156
-
rkey: string;
157
-
};
158
-
},
159
-
) => Promise<{ ok: boolean; data: { value: T } }>;
145
+
try {
146
+
const { rpc } = await createAtprotoClient({
147
+
service: endpoint,
148
+
});
149
+
const res = await (
150
+
rpc as unknown as {
151
+
get: (
152
+
nsid: string,
153
+
opts: {
154
+
params: {
155
+
repo: string;
156
+
collection: string;
157
+
rkey: string;
158
+
};
159
+
},
160
+
) => Promise<{ ok: boolean; data: { value: T } }>;
161
+
}
162
+
).get("com.atproto.repo.getRecord", {
163
+
params: { repo: did, collection, rkey },
164
+
});
165
+
if (!res.ok) throw new Error("Failed to load record");
166
+
return (res.data as { value: T }).value;
167
+
} catch (err) {
168
+
// Provide helpful error for banned/unreachable Bluesky PDSes
169
+
if (endpoint.includes('.bsky.network')) {
170
+
throw new Error(
171
+
`Record unavailable. The Bluesky PDS (${endpoint}) may be unreachable or the account may be banned.`
172
+
);
160
173
}
161
-
).get("com.atproto.repo.getRecord", {
162
-
params: { repo: did, collection, rkey },
163
-
});
164
-
if (!res.ok) throw new Error("Failed to load record");
165
-
return (res.data as { value: T }).value;
174
+
throw err;
175
+
}
166
176
})();
167
177
168
178
return {
+14
-8
lib/hooks/useBlueskyAppview.ts
+14
-8
lib/hooks/useBlueskyAppview.ts
···
308
308
dispatch({ type: "SET_LOADING", loading: true });
309
309
310
310
// Use recordCache.ensure for deduplication and caching
311
-
const { promise, release } = recordCache.ensure<T>(
311
+
const { promise, release } = recordCache.ensure<{ record: T; source: "appview" | "slingshot" | "pds" }>(
312
312
did,
313
313
collection,
314
314
rkey,
315
315
() => {
316
316
const controller = new AbortController();
317
317
318
-
const fetchPromise = (async () => {
318
+
const fetchPromise = (async (): Promise<{ record: T; source: "appview" | "slingshot" | "pds" }> => {
319
319
let lastError: Error | undefined;
320
320
321
321
// Tier 1: Try Bluesky appview API
···
328
328
effectiveAppviewService,
329
329
);
330
330
if (result) {
331
-
return result;
331
+
return { record: result, source: "appview" };
332
332
}
333
333
} catch (err) {
334
334
lastError = err as Error;
···
341
341
const slingshotUrl = resolver.getSlingshotUrl();
342
342
const result = await fetchFromSlingshot<T>(did, collection, rkey, slingshotUrl);
343
343
if (result) {
344
-
return result;
344
+
return { record: result, source: "slingshot" };
345
345
}
346
346
} catch (err) {
347
347
lastError = err as Error;
···
357
357
pdsEndpoint,
358
358
);
359
359
if (result) {
360
-
return result;
360
+
return { record: result, source: "pds" };
361
361
}
362
362
} catch (err) {
363
363
lastError = err as Error;
364
364
}
365
365
366
-
// All tiers failed
366
+
// All tiers failed - provide helpful error for banned/unreachable Bluesky PDSes
367
+
if (pdsEndpoint.includes('.bsky.network')) {
368
+
throw new Error(
369
+
`Record unavailable. The Bluesky PDS (${pdsEndpoint}) may be unreachable or the account may be banned.`
370
+
);
371
+
}
372
+
367
373
throw lastError ?? new Error("Failed to fetch record from all sources");
368
374
})();
369
375
···
377
383
releaseRef.current = release;
378
384
379
385
promise
380
-
.then((record) => {
386
+
.then(({ record, source }) => {
381
387
if (!cancelled) {
382
388
dispatch({
383
389
type: "SET_SUCCESS",
384
390
record,
385
-
source: "appview",
391
+
source,
386
392
});
387
393
}
388
394
})
+4
lib/index.ts
+4
lib/index.ts
···
16
16
export * from "./components/LeafletDocument";
17
17
export * from "./components/TangledRepo";
18
18
export * from "./components/TangledString";
19
+
export * from "./components/CurrentlyPlaying";
20
+
export * from "./components/LastPlayed";
19
21
20
22
// Hooks
21
23
export * from "./hooks/useAtProtoRecord";
···
36
38
export * from "./renderers/LeafletDocumentRenderer";
37
39
export * from "./renderers/TangledRepoRenderer";
38
40
export * from "./renderers/TangledStringRenderer";
41
+
export * from "./renderers/CurrentlyPlayingRenderer";
39
42
40
43
// Types
41
44
export * from "./types/bluesky";
42
45
export * from "./types/grain";
43
46
export * from "./types/leaflet";
44
47
export * from "./types/tangled";
48
+
export * from "./types/teal";
45
49
export * from "./types/theme";
46
50
47
51
// Utilities
+701
lib/renderers/CurrentlyPlayingRenderer.tsx
+701
lib/renderers/CurrentlyPlayingRenderer.tsx
···
1
+
import React, { useState, useEffect } from "react";
2
+
import type { TealActorStatusRecord } from "../types/teal";
3
+
4
+
export interface CurrentlyPlayingRendererProps {
5
+
record: TealActorStatusRecord;
6
+
error?: Error;
7
+
loading: boolean;
8
+
did: string;
9
+
rkey: string;
10
+
colorScheme?: "light" | "dark" | "system";
11
+
autoRefresh?: boolean;
12
+
/** Label to display (e.g., "CURRENTLY PLAYING", "LAST PLAYED"). Defaults to "CURRENTLY PLAYING". */
13
+
label?: string;
14
+
/** Refresh interval in milliseconds. Defaults to 15000 (15 seconds). */
15
+
refreshInterval?: number;
16
+
/** Handle to display in not listening state */
17
+
handle?: string;
18
+
}
19
+
20
+
interface SonglinkPlatform {
21
+
url: string;
22
+
entityUniqueId: string;
23
+
nativeAppUriMobile?: string;
24
+
nativeAppUriDesktop?: string;
25
+
}
26
+
27
+
interface SonglinkResponse {
28
+
linksByPlatform: {
29
+
[platform: string]: SonglinkPlatform;
30
+
};
31
+
entitiesByUniqueId: {
32
+
[id: string]: {
33
+
thumbnailUrl?: string;
34
+
title?: string;
35
+
artistName?: string;
36
+
};
37
+
};
38
+
}
39
+
40
+
export const CurrentlyPlayingRenderer: React.FC<CurrentlyPlayingRendererProps> = ({
41
+
record,
42
+
error,
43
+
loading,
44
+
autoRefresh = true,
45
+
label = "CURRENTLY PLAYING",
46
+
refreshInterval = 15000,
47
+
handle,
48
+
}) => {
49
+
const [albumArt, setAlbumArt] = useState<string | undefined>(undefined);
50
+
const [artworkLoading, setArtworkLoading] = useState(true);
51
+
const [songlinkData, setSonglinkData] = useState<SonglinkResponse | undefined>(undefined);
52
+
const [showPlatformModal, setShowPlatformModal] = useState(false);
53
+
const [refreshKey, setRefreshKey] = useState(0);
54
+
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]);
67
+
68
+
useEffect(() => {
69
+
if (!record) return;
70
+
71
+
const { item } = record;
72
+
const artistName = item.artists[0]?.artistName;
73
+
const trackName = item.trackName;
74
+
75
+
if (!artistName || !trackName) {
76
+
setArtworkLoading(false);
77
+
return;
78
+
}
79
+
80
+
// Reset loading state at start of fetch
81
+
if (refreshKey > 0) {
82
+
setArtworkLoading(true);
83
+
}
84
+
85
+
let cancelled = false;
86
+
87
+
const fetchMusicData = async () => {
88
+
try {
89
+
// Step 1: Check if we have an ISRC - Songlink supports this directly
90
+
if (item.isrc) {
91
+
console.log(`[teal.fm] Attempting ISRC lookup for ${trackName} by ${artistName}`, { isrc: item.isrc });
92
+
const response = await fetch(
93
+
`https://api.song.link/v1-alpha.1/links?platform=isrc&type=song&id=${encodeURIComponent(item.isrc)}&songIfSingle=true`
94
+
);
95
+
if (cancelled) return;
96
+
if (response.ok) {
97
+
const data = await response.json();
98
+
setSonglinkData(data);
99
+
100
+
// Extract album art from Songlink data
101
+
const entityId = data.entityUniqueId;
102
+
const entity = data.entitiesByUniqueId?.[entityId];
103
+
if (entity?.thumbnailUrl) {
104
+
console.log(`[teal.fm] ✓ Found album art via ISRC lookup`);
105
+
setAlbumArt(entity.thumbnailUrl);
106
+
} else {
107
+
console.warn(`[teal.fm] ISRC lookup succeeded but no thumbnail found`);
108
+
}
109
+
setArtworkLoading(false);
110
+
return;
111
+
} else {
112
+
console.warn(`[teal.fm] ISRC lookup failed with status ${response.status}`);
113
+
}
114
+
}
115
+
116
+
// Step 2: Search iTunes Search API to find the track (single request for both artwork and links)
117
+
console.log(`[teal.fm] Attempting iTunes search for: "${trackName}" by "${artistName}"`);
118
+
const iTunesSearchUrl = `https://itunes.apple.com/search?term=${encodeURIComponent(
119
+
`${trackName} ${artistName}`
120
+
)}&media=music&entity=song&limit=1`;
121
+
122
+
const iTunesResponse = await fetch(iTunesSearchUrl);
123
+
124
+
if (cancelled) return;
125
+
126
+
if (iTunesResponse.ok) {
127
+
const iTunesData = await iTunesResponse.json();
128
+
129
+
if (iTunesData.results && iTunesData.results.length > 0) {
130
+
const match = iTunesData.results[0];
131
+
const iTunesId = match.trackId;
132
+
133
+
// Set album artwork immediately (600x600 for high quality)
134
+
const artworkUrl = match.artworkUrl100?.replace('100x100', '600x600') || match.artworkUrl100;
135
+
if (artworkUrl) {
136
+
console.log(`[teal.fm] ✓ Found album art via iTunes search`, { url: artworkUrl });
137
+
setAlbumArt(artworkUrl);
138
+
} else {
139
+
console.warn(`[teal.fm] iTunes match found but no artwork URL`);
140
+
}
141
+
setArtworkLoading(false);
142
+
143
+
// Step 3: Use iTunes ID with Songlink to get all platform links
144
+
console.log(`[teal.fm] Fetching platform links via Songlink (iTunes ID: ${iTunesId})`);
145
+
const songlinkResponse = await fetch(
146
+
`https://api.song.link/v1-alpha.1/links?platform=itunes&type=song&id=${iTunesId}&songIfSingle=true`
147
+
);
148
+
149
+
if (cancelled) return;
150
+
151
+
if (songlinkResponse.ok) {
152
+
const songlinkData = await songlinkResponse.json();
153
+
console.log(`[teal.fm] ✓ Got platform links from Songlink`);
154
+
setSonglinkData(songlinkData);
155
+
return;
156
+
} else {
157
+
console.warn(`[teal.fm] Songlink request failed with status ${songlinkResponse.status}`);
158
+
}
159
+
} else {
160
+
console.warn(`[teal.fm] No iTunes results found for "${trackName}" by "${artistName}"`);
161
+
setArtworkLoading(false);
162
+
}
163
+
} else {
164
+
console.warn(`[teal.fm] iTunes search failed with status ${iTunesResponse.status}`);
165
+
}
166
+
167
+
// Step 4: Fallback - if originUrl is from a supported platform, try it directly
168
+
if (item.originUrl && (
169
+
item.originUrl.includes('spotify.com') ||
170
+
item.originUrl.includes('apple.com') ||
171
+
item.originUrl.includes('youtube.com') ||
172
+
item.originUrl.includes('tidal.com')
173
+
)) {
174
+
console.log(`[teal.fm] Attempting Songlink lookup via originUrl`, { url: item.originUrl });
175
+
const songlinkResponse = await fetch(
176
+
`https://api.song.link/v1-alpha.1/links?url=${encodeURIComponent(item.originUrl)}&songIfSingle=true`
177
+
);
178
+
179
+
if (cancelled) return;
180
+
181
+
if (songlinkResponse.ok) {
182
+
const data = await songlinkResponse.json();
183
+
console.log(`[teal.fm] ✓ Got data from Songlink via originUrl`);
184
+
setSonglinkData(data);
185
+
186
+
// Try to get artwork from Songlink if we don't have it yet
187
+
if (!albumArt) {
188
+
const entityId = data.entityUniqueId;
189
+
const entity = data.entitiesByUniqueId?.[entityId];
190
+
if (entity?.thumbnailUrl) {
191
+
console.log(`[teal.fm] ✓ Found album art via Songlink originUrl lookup`);
192
+
setAlbumArt(entity.thumbnailUrl);
193
+
} else {
194
+
console.warn(`[teal.fm] Songlink lookup succeeded but no thumbnail found`);
195
+
}
196
+
}
197
+
} else {
198
+
console.warn(`[teal.fm] Songlink originUrl lookup failed with status ${songlinkResponse.status}`);
199
+
}
200
+
}
201
+
202
+
if (!albumArt) {
203
+
console.warn(`[teal.fm] ✗ All album art fetch methods failed for "${trackName}" by "${artistName}"`);
204
+
}
205
+
206
+
setArtworkLoading(false);
207
+
} catch (err) {
208
+
console.error(`[teal.fm] ✗ Error fetching music data for "${trackName}" by "${artistName}":`, err);
209
+
setArtworkLoading(false);
210
+
}
211
+
};
212
+
213
+
fetchMusicData();
214
+
215
+
return () => {
216
+
cancelled = true;
217
+
};
218
+
}, [record, refreshKey]); // Add refreshKey to trigger refetch
219
+
220
+
if (error)
221
+
return (
222
+
<div style={{ padding: 8, color: "var(--atproto-color-error)" }}>
223
+
Failed to load status.
224
+
</div>
225
+
);
226
+
if (loading && !record)
227
+
return (
228
+
<div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}>
229
+
Loading…
230
+
</div>
231
+
);
232
+
233
+
const { item } = record;
234
+
235
+
// Check if user is not listening to anything
236
+
const isNotListening = !item.trackName || item.artists.length === 0;
237
+
238
+
// Show "not listening" state
239
+
if (isNotListening) {
240
+
const displayHandle = handle || "User";
241
+
return (
242
+
<div style={styles.notListeningContainer}>
243
+
<div style={styles.notListeningIcon}>
244
+
<svg
245
+
width="80"
246
+
height="80"
247
+
viewBox="0 0 24 24"
248
+
fill="none"
249
+
stroke="currentColor"
250
+
strokeWidth="1.5"
251
+
strokeLinecap="round"
252
+
strokeLinejoin="round"
253
+
>
254
+
<path d="M9 18V5l12-2v13" />
255
+
<circle cx="6" cy="18" r="3" />
256
+
<circle cx="18" cy="16" r="3" />
257
+
</svg>
258
+
</div>
259
+
<div style={styles.notListeningTitle}>
260
+
{displayHandle} isn't listening to anything
261
+
</div>
262
+
<div style={styles.notListeningSubtitle}>Check back soon</div>
263
+
</div>
264
+
);
265
+
}
266
+
267
+
const artistNames = item.artists.map((a) => a.artistName).join(", ");
268
+
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" },
276
+
};
277
+
278
+
const availablePlatforms = songlinkData
279
+
? Object.keys(platformConfig).filter((platform) =>
280
+
songlinkData.linksByPlatform[platform]
281
+
)
282
+
: [];
283
+
284
+
return (
285
+
<>
286
+
<div style={styles.container}>
287
+
{/* Album Artwork */}
288
+
<div style={styles.artworkContainer}>
289
+
{artworkLoading ? (
290
+
<div style={styles.artworkPlaceholder}>
291
+
<div style={styles.loadingSpinner} />
292
+
</div>
293
+
) : albumArt ? (
294
+
<img
295
+
src={albumArt}
296
+
alt={`${item.releaseName || "Album"} cover`}
297
+
style={styles.artwork}
298
+
onError={(e) => {
299
+
console.error("Failed to load album art:", {
300
+
url: albumArt,
301
+
track: item.trackName,
302
+
artist: item.artists[0]?.artistName,
303
+
error: "Image load error"
304
+
});
305
+
e.currentTarget.style.display = "none";
306
+
}}
307
+
/>
308
+
) : (
309
+
<div style={styles.artworkPlaceholder}>
310
+
<svg
311
+
width="64"
312
+
height="64"
313
+
viewBox="0 0 24 24"
314
+
fill="none"
315
+
stroke="currentColor"
316
+
strokeWidth="1.5"
317
+
>
318
+
<circle cx="12" cy="12" r="10" />
319
+
<circle cx="12" cy="12" r="3" />
320
+
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
321
+
</svg>
322
+
</div>
323
+
)}
324
+
</div>
325
+
326
+
{/* Content */}
327
+
<div style={styles.content}>
328
+
<div style={styles.label}>{label}</div>
329
+
<h2 style={styles.trackName}>{item.trackName}</h2>
330
+
<div style={styles.artistName}>{artistNames}</div>
331
+
{item.releaseName && (
332
+
<div style={styles.releaseName}>from {item.releaseName}</div>
333
+
)}
334
+
335
+
{/* Listen Button */}
336
+
{availablePlatforms.length > 0 ? (
337
+
<button
338
+
onClick={() => setShowPlatformModal(true)}
339
+
style={styles.listenButton}
340
+
data-teal-listen-button="true"
341
+
>
342
+
<span>Listen with your Streaming Client</span>
343
+
<svg
344
+
width="16"
345
+
height="16"
346
+
viewBox="0 0 24 24"
347
+
fill="none"
348
+
stroke="currentColor"
349
+
strokeWidth="2"
350
+
strokeLinecap="round"
351
+
strokeLinejoin="round"
352
+
>
353
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
354
+
<polyline points="15 3 21 3 21 9" />
355
+
<line x1="10" y1="14" x2="21" y2="3" />
356
+
</svg>
357
+
</button>
358
+
) : item.originUrl ? (
359
+
<a
360
+
href={item.originUrl}
361
+
target="_blank"
362
+
rel="noopener noreferrer"
363
+
style={styles.listenButton}
364
+
data-teal-listen-button="true"
365
+
>
366
+
<span>Listen on Last.fm</span>
367
+
<svg
368
+
width="16"
369
+
height="16"
370
+
viewBox="0 0 24 24"
371
+
fill="none"
372
+
stroke="currentColor"
373
+
strokeWidth="2"
374
+
strokeLinecap="round"
375
+
strokeLinejoin="round"
376
+
>
377
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
378
+
<polyline points="15 3 21 3 21 9" />
379
+
<line x1="10" y1="14" x2="21" y2="3" />
380
+
</svg>
381
+
</a>
382
+
) : null}
383
+
</div>
384
+
</div>
385
+
386
+
{/* Platform Selection Modal */}
387
+
{showPlatformModal && songlinkData && (
388
+
<div style={styles.modalOverlay} onClick={() => setShowPlatformModal(false)}>
389
+
<div style={styles.modalContent} onClick={(e) => e.stopPropagation()}>
390
+
<div style={styles.modalHeader}>
391
+
<h3 style={styles.modalTitle}>Choose your streaming service</h3>
392
+
<button
393
+
style={styles.closeButton}
394
+
onClick={() => setShowPlatformModal(false)}
395
+
data-teal-close="true"
396
+
>
397
+
×
398
+
</button>
399
+
</div>
400
+
<div style={styles.platformList}>
401
+
{availablePlatforms.map((platform) => {
402
+
const config = platformConfig[platform];
403
+
const link = songlinkData.linksByPlatform[platform];
404
+
return (
405
+
<a
406
+
key={platform}
407
+
href={link.url}
408
+
target="_blank"
409
+
rel="noopener noreferrer"
410
+
style={{
411
+
...styles.platformItem,
412
+
borderLeft: `4px solid ${config.color}`,
413
+
}}
414
+
onClick={() => setShowPlatformModal(false)}
415
+
data-teal-platform="true"
416
+
>
417
+
<span style={styles.platformIcon}>{config.icon}</span>
418
+
<span style={styles.platformName}>{config.name}</span>
419
+
<svg
420
+
width="20"
421
+
height="20"
422
+
viewBox="0 0 24 24"
423
+
fill="none"
424
+
stroke="currentColor"
425
+
strokeWidth="2"
426
+
style={styles.platformArrow}
427
+
>
428
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
429
+
<polyline points="15 3 21 3 21 9" />
430
+
<line x1="10" y1="14" x2="21" y2="3" />
431
+
</svg>
432
+
</a>
433
+
);
434
+
})}
435
+
</div>
436
+
</div>
437
+
</div>
438
+
)}
439
+
</>
440
+
);
441
+
};
442
+
443
+
const styles: Record<string, React.CSSProperties> = {
444
+
container: {
445
+
fontFamily: "system-ui, -apple-system, sans-serif",
446
+
display: "flex",
447
+
flexDirection: "column",
448
+
background: "var(--atproto-color-bg)",
449
+
borderRadius: 16,
450
+
overflow: "hidden",
451
+
maxWidth: 420,
452
+
color: "var(--atproto-color-text)",
453
+
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
454
+
border: "1px solid var(--atproto-color-border)",
455
+
},
456
+
artworkContainer: {
457
+
width: "100%",
458
+
aspectRatio: "1 / 1",
459
+
position: "relative",
460
+
overflow: "hidden",
461
+
},
462
+
artwork: {
463
+
width: "100%",
464
+
height: "100%",
465
+
objectFit: "cover",
466
+
display: "block",
467
+
},
468
+
artworkPlaceholder: {
469
+
width: "100%",
470
+
height: "100%",
471
+
display: "flex",
472
+
alignItems: "center",
473
+
justifyContent: "center",
474
+
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
475
+
color: "rgba(255, 255, 255, 0.5)",
476
+
},
477
+
loadingSpinner: {
478
+
width: 40,
479
+
height: 40,
480
+
border: "3px solid var(--atproto-color-border)",
481
+
borderTop: "3px solid var(--atproto-color-primary)",
482
+
borderRadius: "50%",
483
+
animation: "spin 1s linear infinite",
484
+
},
485
+
content: {
486
+
padding: "24px",
487
+
display: "flex",
488
+
flexDirection: "column",
489
+
gap: "8px",
490
+
},
491
+
label: {
492
+
fontSize: 11,
493
+
fontWeight: 600,
494
+
letterSpacing: "0.1em",
495
+
textTransform: "uppercase",
496
+
color: "var(--atproto-color-text-secondary)",
497
+
marginBottom: "4px",
498
+
},
499
+
trackName: {
500
+
fontSize: 28,
501
+
fontWeight: 700,
502
+
margin: 0,
503
+
lineHeight: 1.2,
504
+
color: "var(--atproto-color-text)",
505
+
},
506
+
artistName: {
507
+
fontSize: 16,
508
+
color: "var(--atproto-color-text-secondary)",
509
+
marginTop: "4px",
510
+
},
511
+
releaseName: {
512
+
fontSize: 14,
513
+
color: "var(--atproto-color-text-secondary)",
514
+
marginTop: "2px",
515
+
},
516
+
listenButton: {
517
+
display: "inline-flex",
518
+
alignItems: "center",
519
+
gap: "8px",
520
+
marginTop: "16px",
521
+
padding: "12px 20px",
522
+
background: "var(--atproto-color-bg-elevated)",
523
+
border: "1px solid var(--atproto-color-border)",
524
+
borderRadius: 24,
525
+
color: "var(--atproto-color-text)",
526
+
fontSize: 14,
527
+
fontWeight: 600,
528
+
textDecoration: "none",
529
+
cursor: "pointer",
530
+
transition: "all 0.2s ease",
531
+
alignSelf: "flex-start",
532
+
},
533
+
modalOverlay: {
534
+
position: "fixed",
535
+
top: 0,
536
+
left: 0,
537
+
right: 0,
538
+
bottom: 0,
539
+
backgroundColor: "rgba(0, 0, 0, 0.85)",
540
+
display: "flex",
541
+
alignItems: "center",
542
+
justifyContent: "center",
543
+
zIndex: 9999,
544
+
backdropFilter: "blur(4px)",
545
+
},
546
+
modalContent: {
547
+
background: "var(--atproto-color-bg)",
548
+
borderRadius: 16,
549
+
padding: 0,
550
+
maxWidth: 450,
551
+
width: "90%",
552
+
maxHeight: "80vh",
553
+
overflow: "auto",
554
+
boxShadow: "0 20px 60px rgba(0, 0, 0, 0.8)",
555
+
border: "1px solid var(--atproto-color-border)",
556
+
},
557
+
modalHeader: {
558
+
display: "flex",
559
+
justifyContent: "space-between",
560
+
alignItems: "center",
561
+
padding: "24px 24px 16px 24px",
562
+
borderBottom: "1px solid var(--atproto-color-border)",
563
+
},
564
+
modalTitle: {
565
+
margin: 0,
566
+
fontSize: 20,
567
+
fontWeight: 700,
568
+
color: "var(--atproto-color-text)",
569
+
},
570
+
closeButton: {
571
+
background: "transparent",
572
+
border: "none",
573
+
color: "var(--atproto-color-text-secondary)",
574
+
fontSize: 32,
575
+
cursor: "pointer",
576
+
padding: 0,
577
+
width: 32,
578
+
height: 32,
579
+
display: "flex",
580
+
alignItems: "center",
581
+
justifyContent: "center",
582
+
borderRadius: "50%",
583
+
transition: "all 0.2s ease",
584
+
lineHeight: 1,
585
+
},
586
+
platformList: {
587
+
padding: "16px",
588
+
display: "flex",
589
+
flexDirection: "column",
590
+
gap: "8px",
591
+
},
592
+
platformItem: {
593
+
display: "flex",
594
+
alignItems: "center",
595
+
gap: "16px",
596
+
padding: "16px",
597
+
background: "var(--atproto-color-bg-hover)",
598
+
borderRadius: 12,
599
+
textDecoration: "none",
600
+
color: "var(--atproto-color-text)",
601
+
transition: "all 0.2s ease",
602
+
cursor: "pointer",
603
+
border: "1px solid var(--atproto-color-border)",
604
+
},
605
+
platformIcon: {
606
+
fontSize: 24,
607
+
width: 32,
608
+
height: 32,
609
+
display: "flex",
610
+
alignItems: "center",
611
+
justifyContent: "center",
612
+
},
613
+
platformName: {
614
+
flex: 1,
615
+
fontSize: 16,
616
+
fontWeight: 600,
617
+
},
618
+
platformArrow: {
619
+
opacity: 0.5,
620
+
transition: "opacity 0.2s ease",
621
+
},
622
+
notListeningContainer: {
623
+
fontFamily: "system-ui, -apple-system, sans-serif",
624
+
display: "flex",
625
+
flexDirection: "column",
626
+
alignItems: "center",
627
+
justifyContent: "center",
628
+
background: "var(--atproto-color-bg)",
629
+
borderRadius: 16,
630
+
padding: "80px 40px",
631
+
maxWidth: 420,
632
+
color: "var(--atproto-color-text-secondary)",
633
+
border: "1px solid var(--atproto-color-border)",
634
+
textAlign: "center",
635
+
},
636
+
notListeningIcon: {
637
+
width: 120,
638
+
height: 120,
639
+
borderRadius: "50%",
640
+
background: "var(--atproto-color-bg-elevated)",
641
+
display: "flex",
642
+
alignItems: "center",
643
+
justifyContent: "center",
644
+
marginBottom: 24,
645
+
color: "var(--atproto-color-text-muted)",
646
+
},
647
+
notListeningTitle: {
648
+
fontSize: 18,
649
+
fontWeight: 600,
650
+
color: "var(--atproto-color-text)",
651
+
marginBottom: 8,
652
+
},
653
+
notListeningSubtitle: {
654
+
fontSize: 14,
655
+
color: "var(--atproto-color-text-secondary)",
656
+
},
657
+
};
658
+
659
+
// Add keyframes and hover styles
660
+
if (typeof document !== "undefined") {
661
+
const styleId = "teal-status-styles";
662
+
if (!document.getElementById(styleId)) {
663
+
const styleElement = document.createElement("style");
664
+
styleElement.id = styleId;
665
+
styleElement.textContent = `
666
+
@keyframes spin {
667
+
0% { transform: rotate(0deg); }
668
+
100% { transform: rotate(360deg); }
669
+
}
670
+
671
+
button[data-teal-listen-button]:hover:not(:disabled),
672
+
a[data-teal-listen-button]:hover {
673
+
background: var(--atproto-color-bg-pressed) !important;
674
+
border-color: var(--atproto-color-border-hover) !important;
675
+
transform: translateY(-2px);
676
+
}
677
+
678
+
button[data-teal-listen-button]:disabled {
679
+
opacity: 0.5;
680
+
cursor: not-allowed;
681
+
}
682
+
683
+
button[data-teal-close]:hover {
684
+
background: var(--atproto-color-bg-hover) !important;
685
+
color: var(--atproto-color-text) !important;
686
+
}
687
+
688
+
a[data-teal-platform]:hover {
689
+
background: var(--atproto-color-bg-pressed) !important;
690
+
transform: translateX(4px);
691
+
}
692
+
693
+
a[data-teal-platform]:hover svg {
694
+
opacity: 1 !important;
695
+
}
696
+
`;
697
+
document.head.appendChild(styleElement);
698
+
}
699
+
}
700
+
701
+
export default CurrentlyPlayingRenderer;
+4
-4
lib/renderers/TangledRepoRenderer.tsx
+4
-4
lib/renderers/TangledRepoRenderer.tsx
···
105
105
<div
106
106
style={{
107
107
...base.container,
108
-
background: `var(--atproto-color-bg-elevated)`,
108
+
background: `var(--atproto-color-bg)`,
109
109
borderWidth: "1px",
110
110
borderStyle: "solid",
111
111
borderColor: `var(--atproto-color-border)`,
···
116
116
<div
117
117
style={{
118
118
...base.header,
119
-
background: `var(--atproto-color-bg-elevated)`,
119
+
background: `var(--atproto-color-bg)`,
120
120
}}
121
121
>
122
122
<div style={base.headerTop}>
···
166
166
<div
167
167
style={{
168
168
...base.description,
169
-
background: `var(--atproto-color-bg-elevated)`,
169
+
background: `var(--atproto-color-bg)`,
170
170
color: `var(--atproto-color-text-secondary)`,
171
171
}}
172
172
>
···
178
178
<div
179
179
style={{
180
180
...base.languageSection,
181
-
background: `var(--atproto-color-bg-elevated)`,
181
+
background: `var(--atproto-color-bg)`,
182
182
}}
183
183
>
184
184
{/* Languages */}
+59
-47
lib/styles.css
+59
-47
lib/styles.css
···
7
7
8
8
:root {
9
9
/* Light theme colors (default) */
10
-
--atproto-color-bg: #ffffff;
11
-
--atproto-color-bg-elevated: #f8fafc;
12
-
--atproto-color-bg-secondary: #f1f5f9;
10
+
--atproto-color-bg: #f5f7f9;
11
+
--atproto-color-bg-elevated: #f8f9fb;
12
+
--atproto-color-bg-secondary: #edf1f5;
13
13
--atproto-color-text: #0f172a;
14
14
--atproto-color-text-secondary: #475569;
15
15
--atproto-color-text-muted: #64748b;
16
-
--atproto-color-border: #e2e8f0;
17
-
--atproto-color-border-subtle: #cbd5e1;
16
+
--atproto-color-border: #d6dce3;
17
+
--atproto-color-border-subtle: #c1cad4;
18
+
--atproto-color-border-hover: #94a3b8;
18
19
--atproto-color-link: #2563eb;
19
20
--atproto-color-link-hover: #1d4ed8;
20
21
--atproto-color-error: #dc2626;
21
-
--atproto-color-button-bg: #f1f5f9;
22
-
--atproto-color-button-hover: #e2e8f0;
22
+
--atproto-color-primary: #2563eb;
23
+
--atproto-color-button-bg: #edf1f5;
24
+
--atproto-color-button-hover: #e3e9ef;
23
25
--atproto-color-button-text: #0f172a;
24
-
--atproto-color-code-bg: #f1f5f9;
25
-
--atproto-color-code-border: #e2e8f0;
26
-
--atproto-color-blockquote-border: #cbd5e1;
27
-
--atproto-color-blockquote-bg: #f8fafc;
28
-
--atproto-color-hr: #e2e8f0;
29
-
--atproto-color-image-bg: #f1f5f9;
26
+
--atproto-color-bg-hover: #f0f3f6;
27
+
--atproto-color-bg-pressed: #e3e9ef;
28
+
--atproto-color-code-bg: #edf1f5;
29
+
--atproto-color-code-border: #d6dce3;
30
+
--atproto-color-blockquote-border: #c1cad4;
31
+
--atproto-color-blockquote-bg: #f0f3f6;
32
+
--atproto-color-hr: #d6dce3;
33
+
--atproto-color-image-bg: #edf1f5;
30
34
--atproto-color-highlight: #fef08a;
31
35
}
32
36
33
37
/* Dark theme - can be applied via [data-theme="dark"] or .dark class */
34
38
[data-theme="dark"],
35
39
.dark {
36
-
--atproto-color-bg: #0f172a;
37
-
--atproto-color-bg-elevated: #1e293b;
38
-
--atproto-color-bg-secondary: #0b1120;
39
-
--atproto-color-text: #e2e8f0;
40
-
--atproto-color-text-secondary: #94a3b8;
41
-
--atproto-color-text-muted: #64748b;
42
-
--atproto-color-border: #1e293b;
43
-
--atproto-color-border-subtle: #334155;
40
+
--atproto-color-bg: #141b22;
41
+
--atproto-color-bg-elevated: #1a222a;
42
+
--atproto-color-bg-secondary: #0f161c;
43
+
--atproto-color-text: #fafafa;
44
+
--atproto-color-text-secondary: #a1a1aa;
45
+
--atproto-color-text-muted: #71717a;
46
+
--atproto-color-border: #1f2933;
47
+
--atproto-color-border-subtle: #2d3748;
48
+
--atproto-color-border-hover: #4a5568;
44
49
--atproto-color-link: #60a5fa;
45
50
--atproto-color-link-hover: #93c5fd;
46
51
--atproto-color-error: #ef4444;
47
-
--atproto-color-button-bg: #1e293b;
48
-
--atproto-color-button-hover: #334155;
49
-
--atproto-color-button-text: #e2e8f0;
50
-
--atproto-color-code-bg: #0b1120;
51
-
--atproto-color-code-border: #1e293b;
52
-
--atproto-color-blockquote-border: #334155;
53
-
--atproto-color-blockquote-bg: #1e293b;
54
-
--atproto-color-hr: #334155;
55
-
--atproto-color-image-bg: #1e293b;
52
+
--atproto-color-primary: #3b82f6;
53
+
--atproto-color-button-bg: #1a222a;
54
+
--atproto-color-button-hover: #243039;
55
+
--atproto-color-button-text: #fafafa;
56
+
--atproto-color-bg-hover: #1a222a;
57
+
--atproto-color-bg-pressed: #243039;
58
+
--atproto-color-code-bg: #0f161c;
59
+
--atproto-color-code-border: #1f2933;
60
+
--atproto-color-blockquote-border: #2d3748;
61
+
--atproto-color-blockquote-bg: #1a222a;
62
+
--atproto-color-hr: #243039;
63
+
--atproto-color-image-bg: #1a222a;
56
64
--atproto-color-highlight: #854d0e;
57
65
}
58
66
···
60
68
@media (prefers-color-scheme: dark) {
61
69
:root:not([data-theme]),
62
70
:root[data-theme="system"] {
63
-
--atproto-color-bg: #0f172a;
64
-
--atproto-color-bg-elevated: #1e293b;
65
-
--atproto-color-bg-secondary: #0b1120;
66
-
--atproto-color-text: #e2e8f0;
67
-
--atproto-color-text-secondary: #94a3b8;
68
-
--atproto-color-text-muted: #64748b;
69
-
--atproto-color-border: #1e293b;
70
-
--atproto-color-border-subtle: #334155;
71
+
--atproto-color-bg: #141b22;
72
+
--atproto-color-bg-elevated: #1a222a;
73
+
--atproto-color-bg-secondary: #0f161c;
74
+
--atproto-color-text: #fafafa;
75
+
--atproto-color-text-secondary: #a1a1aa;
76
+
--atproto-color-text-muted: #71717a;
77
+
--atproto-color-border: #1f2933;
78
+
--atproto-color-border-subtle: #2d3748;
79
+
--atproto-color-border-hover: #4a5568;
71
80
--atproto-color-link: #60a5fa;
72
81
--atproto-color-link-hover: #93c5fd;
73
82
--atproto-color-error: #ef4444;
74
-
--atproto-color-button-bg: #1e293b;
75
-
--atproto-color-button-hover: #334155;
76
-
--atproto-color-button-text: #e2e8f0;
77
-
--atproto-color-code-bg: #0b1120;
78
-
--atproto-color-code-border: #1e293b;
79
-
--atproto-color-blockquote-border: #334155;
80
-
--atproto-color-blockquote-bg: #1e293b;
81
-
--atproto-color-hr: #334155;
82
-
--atproto-color-image-bg: #1e293b;
83
+
--atproto-color-primary: #3b82f6;
84
+
--atproto-color-button-bg: #1a222a;
85
+
--atproto-color-button-hover: #243039;
86
+
--atproto-color-button-text: #fafafa;
87
+
--atproto-color-bg-hover: #1a222a;
88
+
--atproto-color-bg-pressed: #243039;
89
+
--atproto-color-code-bg: #0f161c;
90
+
--atproto-color-code-border: #1f2933;
91
+
--atproto-color-blockquote-border: #2d3748;
92
+
--atproto-color-blockquote-bg: #1a222a;
93
+
--atproto-color-hr: #243039;
94
+
--atproto-color-image-bg: #1a222a;
83
95
--atproto-color-highlight: #854d0e;
84
96
}
85
97
}
+40
lib/types/teal.ts
+40
lib/types/teal.ts
···
1
+
/**
2
+
* teal.fm record types for music listening history
3
+
* Specification: fm.teal.alpha.actor.status and fm.teal.alpha.feed.play
4
+
*/
5
+
6
+
export interface TealArtist {
7
+
artistName: string;
8
+
artistMbId?: string;
9
+
}
10
+
11
+
export interface TealPlayItem {
12
+
artists: TealArtist[];
13
+
originUrl?: string;
14
+
trackName: string;
15
+
playedTime: string;
16
+
releaseName?: string;
17
+
recordingMbId?: string;
18
+
releaseMbId?: string;
19
+
submissionClientAgent?: string;
20
+
musicServiceBaseDomain?: string;
21
+
isrc?: string;
22
+
duration?: number;
23
+
}
24
+
25
+
/**
26
+
* fm.teal.alpha.actor.status - The last played song
27
+
*/
28
+
export interface TealActorStatusRecord {
29
+
$type: "fm.teal.alpha.actor.status";
30
+
item: TealPlayItem;
31
+
time: string;
32
+
expiry?: string;
33
+
}
34
+
35
+
/**
36
+
* fm.teal.alpha.feed.play - A single play record
37
+
*/
38
+
export interface TealFeedPlayRecord extends TealPlayItem {
39
+
$type: "fm.teal.alpha.feed.play";
40
+
}
+32
src/App.tsx
+32
src/App.tsx
···
13
13
import { BlueskyPostList } from "../lib/components/BlueskyPostList";
14
14
import { BlueskyQuotePost } from "../lib/components/BlueskyQuotePost";
15
15
import { GrainGallery } from "../lib/components/GrainGallery";
16
+
import { CurrentlyPlaying } from "../lib/components/CurrentlyPlaying";
17
+
import { LastPlayed } from "../lib/components/LastPlayed";
16
18
import { useDidResolution } from "../lib/hooks/useDidResolution";
17
19
import { useLatestRecord } from "../lib/hooks/useLatestRecord";
18
20
import type { FeedPostRecord } from "../lib/types/bluesky";
···
302
304
did="kat.meangirls.online"
303
305
rkey="3m2e2qikseq2f"
304
306
/>
307
+
</section>
308
+
<section style={panelStyle}>
309
+
<h3 style={sectionHeaderStyle}>
310
+
teal.fm Currently Playing
311
+
</h3>
312
+
<p
313
+
style={{
314
+
fontSize: 12,
315
+
color: `var(--demo-text-secondary)`,
316
+
margin: "0 0 8px",
317
+
}}
318
+
>
319
+
Currently playing track from teal.fm (refreshes every 15s)
320
+
</p>
321
+
<CurrentlyPlaying did="nekomimi.pet" />
322
+
</section>
323
+
<section style={panelStyle}>
324
+
<h3 style={sectionHeaderStyle}>
325
+
teal.fm Last Played
326
+
</h3>
327
+
<p
328
+
style={{
329
+
fontSize: 12,
330
+
color: `var(--demo-text-secondary)`,
331
+
margin: "0 0 8px",
332
+
}}
333
+
>
334
+
Most recent play from teal.fm feed
335
+
</p>
336
+
<LastPlayed did="nekomimi.pet" />
305
337
</section>
306
338
</div>
307
339
<div style={columnStackStyle}>