diff --git a/lexicon.zip b/lexicon.zip new file mode 100644 index 0000000000000000000000000000000000000000..8100f0d586f4b3c40bd50eb94f1e02f5b3b2e835 GIT binary patch literal 1080 zcmWIWW@h1H0D;WDx)=}*!yF7U3^}P4naTNi`k^7549u-kr!zsgw1S&~k>x8R0|Sc) z0|NtY6Znu!D9A}nP9@Tu3N)99Vwh80Qk0sQ3o&kq)alHnQl~SqI1A)F4hE2MTOLo( z?qg(Nc+AScAdkzqvdom!e7&sV{5-IKK?Z{`n!$5J4h9`o5ZL=%TjEQE@#Cw}f7|ZO zk<-YTeN{l|X33hl|8_ehIbB+C)miOB+4r}f${x-!Jof%&2y=Z>jMQB2RcjR|Pyf(( zDWp=F`$E(DJ<0u+*N<;-f4cZpkjDw>O$sG-F09Kwhi%B8^Y_iQJoPm?A2`l0y~(?v zd4KTiyV@RHjf~qr+*`yt|3Rwtf^}P@bQDbl-9Oy(Z|Jyjwe@Nv|FY9rJlP+Fg9;-A zWe(`EXI#KqIkZ3#PP@bZ;nK(-YBzTO+cXQh8Ef3S7d>XoI-xI_;e z`mnK2w`iYE=)5z}(le*6lGd*iKVLA(enrBY`2NRLt5_>{7xDURT79kS-wDoNs}IKN z7;Jg@U)74KsQkh7c{zt#HchzTc&6P%rDU$&x{_aI{+_#btv<+m?Wp+T?W*qUg;}mL z{W)A3r(yGByI8)`EOx&wqF)|VK0CSTN?y$QxS40_r@!G2@MdI^W5$(&C7_8}fZ?qp zh>1wgtdR7K7Pt6J<6}TJ&53~lo;zS^o3NRnqzG~c42z { - if (allVideosCache) return allVideosCache; - - const res = await fetch(`${UFOS_API}/records?collection=${COLLECTION}`); - if (!res.ok) throw new Error(`Failed to fetch records: ${res.status}`); - - const ufosRecords: UfosRecord[] = await res.json(); + hasMore: boolean; + endCursor?: string; +}> { + const res = await fetch(VODSLICE_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: VIDEOS_QUERY, + variables: { first: pageSize, after: cursor || null } + }) + }); - // Convert UFOs format to our VideoRecord format, sorted newest first - allVideosCache = ufosRecords - .sort((a, b) => b.time_us - a.time_us) - .map((r) => ({ - uri: `at://${r.did}/${r.collection}/${r.rkey}`, - cid: '', - value: r.record - })); + if (!res.ok) throw new Error(`Failed to fetch videos: ${res.status}`); + const json = await res.json(); + const data = json.data?.placeStreamVideo; + if (!data) throw new Error('No data in GraphQL response'); + + const records: VideoRecord[] = data.edges.map((edge: VodSliceEdge) => { + const n = edge.node; + + // Normalize the livestream field — GraphQL returns it as either a string (JSON) or object + let livestream: { cid: string; uri: string } | undefined; + if (typeof n.livestream === 'string') { + try { livestream = JSON.parse(n.livestream); } catch { /* ignore */ } + } else if (n.livestream && typeof n.livestream === 'object') { + livestream = n.livestream as { uri: string; cid: string }; + } + + return { + uri: n.uri, + cid: n.cid || '', + value: { + $type: COLLECTION, + title: n.title, + source: { ...n.source, $type: 'place.stream.muxl.defs#archiveBlob' }, + creator: n.creator, + duration: n.duration, + createdAt: n.createdAt, + livestream + } + }; + }); - return allVideosCache; + return { + records, + hasMore: data.pageInfo.hasNextPage, + endCursor: data.pageInfo.endCursor + }; } -/** Return a page of videos from the cached full list */ -export async function listVideos(page = 0, pageSize = 9): Promise<{ records: VideoRecord[]; hasMore: boolean }> { - const all = await fetchAllVideos(); - const start = page * pageSize; - const records = all.slice(start, start + pageSize); - return { records, hasMore: start + pageSize < all.length }; +/** Fetch all videos (for deep-link lookup). Uses pagination internally. */ +export async function fetchAllVideos(): Promise { + const all: VideoRecord[] = []; + let cursor: string | undefined; + let hasMore = true; + while (hasMore) { + const page = await listVideos(cursor, 50); + all.push(...page.records); + hasMore = page.hasMore; + cursor = page.endCursor; + } + return all; } /** Build an HLS playlist URL from an at:// video URI */ @@ -144,51 +201,45 @@ export async function getProfile(actor: string): Promise { return res.json(); } -// --- Handle resolution --- +// --- Identity resolution via Slingshot --- -const handleCache = new Map(); +interface MiniDoc { + did: string; + handle: string; + pds: string; +} -/** Resolve a DID to a human-readable @handle via the PLC directory */ -export async function resolveHandle(did: string): Promise { - if (handleCache.has(did)) return handleCache.get(did)!; +const identityCache = new Map(); + +/** Resolve a DID to handle + PDS via the Slingshot cached resolver */ +async function resolveMiniDoc(did: string): Promise { + if (identityCache.has(did)) return identityCache.get(did)!; try { - const res = await fetch(`https://plc.directory/${did}`); - if (!res.ok) return did; - const doc = await res.json(); - const aka = doc.alsoKnownAs?.[0]; - const handle = aka ? aka.replace('at://', '@') : did; - handleCache.set(did, handle); - return handle; + const res = await fetch(`${SLINGSHOT_API}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(did)}`); + if (!res.ok) return null; + const doc: MiniDoc = await res.json(); + identityCache.set(did, doc); + return doc; } catch { - return did; + return null; } } -// --- PDS resolution --- - -const pdsCache = new Map(); +/** Resolve a DID to a human-readable @handle */ +export async function resolveHandle(did: string): Promise { + const doc = await resolveMiniDoc(did); + return doc ? `@${doc.handle}` : did; +} /** Resolve a DID to its PDS (Personal Data Server) endpoint URL */ export async function resolvePds(did: string): Promise { - if (pdsCache.has(did)) return pdsCache.get(did)!; - try { - const res = await fetch(`https://plc.directory/${did}`); - if (!res.ok) return null; - const doc = await res.json(); - const svc = doc.service?.find( - (s: any) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' - ); - const endpoint = svc?.serviceEndpoint ?? null; - if (endpoint) pdsCache.set(did, endpoint); - return endpoint; - } catch { - return null; - } + const doc = await resolveMiniDoc(did); + return doc?.pds ?? null; } // --- Thumbnail resolution --- -// Parse an at:// URI into { repo, collection, rkey } +/** Parse an at:// URI into { repo, collection, rkey } */ function parseAtUri(uri: string): { repo: string; collection: string; rkey: string } | null { const m = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/); if (!m) return null; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ba7da4f..6db8b40 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -25,7 +25,8 @@ let selectedAvatar = $state(""); let error = $state(""); - // Pagination state + // Pagination state — store cursor history so we can go back + let cursorHistory: (string | undefined)[] = $state([undefined]); let pageIndex = $state(0); let hasMore = $state(true); @@ -33,9 +34,13 @@ loading = true; error = ""; try { - const res = await listVideos(pageIndex, PAGE_SIZE); + const res = await listVideos(cursorHistory[pageIndex], PAGE_SIZE); videos = res.records; hasMore = res.hasMore; + // Store the next page's cursor if we haven't visited it yet + if (res.hasMore && res.endCursor && cursorHistory.length <= pageIndex + 1) { + cursorHistory = [...cursorHistory, res.endCursor]; + } } catch (e: any) { error = e.message; } @@ -61,7 +66,6 @@ selectedAvatar = ""; resolveHandle(video.value.creator).then((h) => { selectedHandle = h; - // Fetch avatar from profile const handle = h.replace('@', ''); getProfile(handle).then((p) => { if (p.avatar) selectedAvatar = p.avatar; }).catch(() => {}); });