An entry for the streamplace vod showcase
at main 134 lines 3.7 kB view raw
1/** 2 * API client for the at-run VOD bundle 3 */ 4 5const RUNNER_URL = import.meta.env.VITE_RUNNER_URL || "http://code-atrun-vyl7lg-cc8112-157-173-113-6.traefik.me" 6const BUNDLE_PATH = import.meta.env.VITE_BUNDLE_PATH || "bundle/did:plc:p3cygo5s7wru2argeci6wfv6/atmosphereconf-vod/latest" 7 8export interface Video { 9 uri: string 10 title: string 11 creator: string 12 duration: number 13 createdAt: string 14} 15 16export interface StreamInfo { 17 quality: string 18 width: number 19 height: number 20 bandwidth: number 21 codecs: string 22 frameRate?: number 23} 24 25export interface VideoStreamsResult { 26 uri: string 27 streams: StreamInfo[] 28 audioTracks: Array<{ name: string; default: boolean }> 29} 30 31export interface VideoMetadata { 32 thumbnail: string // base64 encoded JPEG (single frame) 33 spriteSheet: string // base64 encoded JPEG (sprite grid for preview) 34 vtt: string // base64 encoded WebVTT 35} 36 37export interface Profile { 38 did: string 39 handle: string 40 displayName?: string 41 avatar?: string 42 description?: string 43} 44 45async function callEndpoint<T>(endpoint: string, args: unknown = {}): Promise<T> { 46 const url = `${RUNNER_URL}/${BUNDLE_PATH}/${endpoint}` 47 const res = await fetch(url, { 48 method: "POST", 49 headers: { "Content-Type": "application/json" }, 50 body: JSON.stringify(args), 51 }) 52 53 if (!res.ok) { 54 const error = await res.json().catch(() => ({ error: res.statusText })) 55 throw new Error(error.error || "Request failed") 56 } 57 58 return res.json() 59} 60 61export async function listVideos( 62 limit?: number, 63 cursor?: string 64): Promise<{ videos: Video[]; cursor?: string }> { 65 return callEndpoint("listVideos", { limit, cursor }) 66} 67 68export async function getVideo(uri: string): Promise<Video> { 69 return callEndpoint("getVideo", { uri }) 70} 71 72export async function getVideoStreams(uri: string): Promise<VideoStreamsResult> { 73 return callEndpoint("getVideoStreams", { uri }) 74} 75 76interface TaskResponse<T> { 77 status: "pending" | "running" | "complete" | "error" 78 taskId: string 79 result?: T 80 error?: string 81 cachedAt?: string 82} 83 84/** 85 * Call a task endpoint with automatic retry until complete 86 */ 87async function callTask<T>(endpoint: string, args: unknown, maxRetries = 60, retryDelay = 2000): Promise<T> { 88 for (let i = 0; i < maxRetries; i++) { 89 const response = await callEndpoint<TaskResponse<T>>(endpoint, args) 90 91 if (response.status === "complete") { 92 return response.result as T 93 } 94 95 if (response.status === "error") { 96 throw new Error(response.error || "Task failed") 97 } 98 99 // pending or running - wait and retry 100 await new Promise(resolve => setTimeout(resolve, retryDelay)) 101 } 102 103 throw new Error("Task timeout - max retries exceeded") 104} 105 106export async function getVideoMetadata(uri: string): Promise<VideoMetadata> { 107 return callTask("videoMetadata", { uri }) 108} 109 110export async function getProfile(did: string): Promise<Profile> { 111 return callEndpoint("getProfile", { did }) 112} 113 114export function getPlaylistUrl(uri: string, quality?: string): string { 115 const args: Record<string, string> = { uri } 116 if (quality) args.quality = quality 117 const queryString = new URLSearchParams(args).toString() 118 return `${RUNNER_URL}/${BUNDLE_PATH}/getPlaylist?${queryString}` 119} 120 121/** 122 * Format duration from nanoseconds to human readable 123 */ 124export function formatDuration(nanoseconds: number): string { 125 const totalSeconds = Math.floor(nanoseconds / 1_000_000_000) 126 const hours = Math.floor(totalSeconds / 3600) 127 const minutes = Math.floor((totalSeconds % 3600) / 60) 128 const seconds = totalSeconds % 60 129 130 if (hours > 0) { 131 return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}` 132 } 133 return `${minutes}:${seconds.toString().padStart(2, "0")}` 134}