Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365/** * AT Protocol API client for fetching videos, resolving handles/profiles, * and looking up thumbnails across the network. */
/** The AT Protocol lexicon collection for stream.place videos */export const COLLECTION = 'place.stream.video';
/** HLS playlist endpoint — takes an at:// URI and returns an m3u8 manifest */export const PLAYBACK_BASE = 'https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist';
/** VodSlice GraphQL API — indexes all place.stream.video records with rich metadata */export const VODSLICE_API = 'https://vodslice.sky.boo/graphql';
/** Slingshot identity resolver — fast cached DID→handle+PDS resolution */export const SLINGSHOT_API = 'https://slingshot.microcosm.blue';
/** A video record in our normalized format */export interface VideoRecord { uri: string; cid: string; value: { $type: string; title: string; source: { ref: string; size: number; $type: string; mimeType: string; }; creator: string; duration: number; // nanoseconds createdAt: string; livestream?: { cid: string; uri: string; }; };}
/** GraphQL query for fetching paginated videos with all needed fields */const VIDEOS_QUERY = `query GetVideos($first: Int, $after: String) { placeStreamVideo(first: $first, after: $after) { edges { node { uri cid creator title duration createdAt livestream source { ref size mimeType } } cursor } pageInfo { hasNextPage endCursor } }}`;
/** Response shape from the VodSlice GraphQL API */interface VodSliceEdge { node: { uri: string; cid: string; creator: string; title: string; duration: number; createdAt: string; livestream: string | { uri: string; cid: string } | null; source: { ref: string; size: number; mimeType: string }; }; cursor: string;}
/** Fetch a page of videos from the VodSlice GraphQL API with cursor-based pagination */export async function listVideos(cursor?: string, pageSize = 9): Promise<{ records: VideoRecord[]; 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 } }) });
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 { records, hasMore: data.pageInfo.hasNextPage, endCursor: data.pageInfo.endCursor };}
/** Fetch paginated videos by a specific creator DID */export async function listVideosByCreator(creatorDid: string, cursor?: string, pageSize = 9): Promise<{ records: VideoRecord[]; hasMore: boolean; endCursor?: string;}> { const query = `query GetVideosByCreator($creatorDid: String!, $first: Int, $after: String) { placeStreamVideo(first: $first, after: $after, where: { creator: { eq: $creatorDid } }) { edges { node { uri cid creator title duration createdAt livestream source { ref size mimeType } } } pageInfo { hasNextPage endCursor } }}`;
const res = await fetch(VODSLICE_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables: { creatorDid, first: pageSize, after: cursor || null } }) });
if (!res.ok) throw new Error(`Failed to fetch creator videos: ${res.status}`); const json = await res.json(); const data = json.data?.placeStreamVideo; if (!data) return { records: [], hasMore: false };
const records = data.edges.map((edge: VodSliceEdge) => { const n = edge.node; 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 { records, hasMore: data.pageInfo.hasNextPage, endCursor: data.pageInfo.endCursor };}
/** Fetch all videos (for deep-link lookup). Uses pagination internally. */export async function fetchAllVideos(): Promise<VideoRecord[]> { 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 */export function getPlaylistUrl(uri: string): string { return `${PLAYBACK_BASE}?uri=${encodeURIComponent(uri)}`;}
/** Format nanosecond duration to human-readable "H:MM:SS" or "M:SS" */export function formatDuration(nanos: number): string { const totalSeconds = Math.floor(nanos / 1_000_000_000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) { return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } return `${minutes}:${String(seconds).padStart(2, '0')}`;}
export function formatDate(iso: string): string { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });}
// --- Profile ---
export const BSKY_PUBLIC_API = 'https://public.api.bsky.app';
export interface BskyProfile { did: string; handle: string; displayName?: string; description?: string; avatar?: string; banner?: string; followersCount?: number; followsCount?: number; postsCount?: number; createdAt?: string;}
/** Fetch a Bluesky profile by handle or DID via the public API */export async function getProfile(actor: string): Promise<BskyProfile> { const params = new URLSearchParams({ actor }); const res = await fetch(`${BSKY_PUBLIC_API}/xrpc/app.bsky.actor.getProfile?${params}`); if (!res.ok) throw new Error(`Failed to fetch profile: ${res.status}`); return res.json();}
// --- Identity resolution via Slingshot ---
interface MiniDoc { did: string; handle: string; pds: string;}
const identityCache = new Map<string, MiniDoc>();
/** Resolve a DID to handle + PDS via the Slingshot cached resolver */async function resolveMiniDoc(did: string): Promise<MiniDoc | null> { if (identityCache.has(did)) return identityCache.get(did)!; try { 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 null; }}
/** Resolve a DID to its identity (handle + PDS) */export async function resolveIdentity(did: string): Promise<MiniDoc | null> { return resolveMiniDoc(did);}
/** Resolve a DID to a human-readable @handle */export async function resolveHandle(did: string): Promise<string> { 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<string | null> { const doc = await resolveMiniDoc(did); return doc?.pds ?? null;}
// --- Thumbnail resolution ---
/** 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; return { repo: m[1], collection: m[2], rkey: m[3] };}
const thumbCache = new Map<string, string | null>();
/** * Given a video record, resolve the thumbnail URL by fetching the linked * livestream record from the creator's PDS and extracting the thumb blob ref. */export async function getThumbnailUrl(video: VideoRecord): Promise<string | null> { const lsUri = video.value.livestream?.uri; if (!lsUri) return null; if (thumbCache.has(lsUri)) return thumbCache.get(lsUri)!;
try { const parsed = parseAtUri(lsUri); if (!parsed) return null;
const pds = await resolvePds(parsed.repo); if (!pds) return null;
const params = new URLSearchParams({ repo: parsed.repo, collection: parsed.collection, rkey: parsed.rkey }); const res = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?${params}`); if (!res.ok) return null;
const record = await res.json(); const thumbRef = record.value?.thumb?.ref?.$link; if (!thumbRef) return null;
const blobUrl = `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(parsed.repo)}&cid=${encodeURIComponent(thumbRef)}`; thumbCache.set(lsUri, blobUrl); return blobUrl; } catch { thumbCache.set(lsUri, null); return null; }}