An entry for the streamplace vod showcase
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}