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

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

+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}>