A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
41
fork

Configure Feed

Select the types of activity you want to include in your feed.

change dark mode to be a slated dark blue theme from the harsh dark blue that twitter and bluesky use, add teal.fm components

+1866 -79
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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}>