Live video on the AT Protocol
79
fork

Configure Feed

Select the types of activity you want to include in your feed.

at eli/godeps 334 lines 9.2 kB view raw
1import { AppBskyFeedPost, BlobRef, RichText } from "@atproto/api"; 2import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 3import { StreamplaceAgent } from "streamplace/src/agent"; 4import { PlaceStreamLivestream } from "streamplace/src/lexicons"; 5import { LivestreamViewHydrated } from "streamplace/src/useful-types"; 6import { useUrl } from "./streamplace-store"; 7import { usePDSAgent } from "./xrpc"; 8 9import PackageJson from "../../package.json"; 10 11import { useEffect, useRef } from "react"; 12import { Platform } from "react-native"; 13import { getBrowserName } from "../lib/browser"; 14 15const useUploadThumbnail = () => { 16 const abortRef = useRef<AbortController | null>(null); 17 18 useEffect(() => { 19 return () => { 20 // On unmount, abort any ongoing upload 21 abortRef.current?.abort(); 22 }; 23 }, []); 24 25 const uploadThumbnail = async ( 26 pdsAgent: StreamplaceAgent, 27 customThumbnail?: Blob, 28 ) => { 29 if (!customThumbnail) return undefined; 30 31 abortRef.current = new AbortController(); 32 const { signal } = abortRef.current; 33 34 const maxTries = 3; 35 let lastError: unknown = null; 36 37 for (let tries = 0; tries < maxTries; tries++) { 38 try { 39 const thumbnail = await pdsAgent.uploadBlob(customThumbnail, { 40 signal, 41 }); 42 if ( 43 thumbnail.success && 44 thumbnail.data.blob.size === customThumbnail.size 45 ) { 46 console.log("Successfully uploaded thumbnail"); 47 return thumbnail.data.blob; 48 } else { 49 console.warn( 50 `Blob size mismatch (attempt ${tries + 1}): received ${thumbnail.data.blob.size}, expected ${customThumbnail.size}`, 51 ); 52 } 53 } catch (e) { 54 if (signal.aborted) { 55 console.warn("Upload aborted"); 56 return undefined; 57 } 58 lastError = e; 59 console.warn(`Error uploading thumbnail (attempt ${tries + 1}): ${e}`); 60 } 61 } 62 63 throw new Error( 64 `Could not successfully upload blob after ${maxTries} attempts. Last error: ${lastError}`, 65 ); 66 }; 67 68 return uploadThumbnail; 69}; 70 71async function createNewPost( 72 agent: StreamplaceAgent, 73 record: AppBskyFeedPost.Record, 74): Promise<{ uri: string; cid: string }> { 75 try { 76 const post = await agent.post(record); 77 78 return { uri: post.uri, cid: post.cid }; 79 } catch (error) { 80 console.error("Error creating new post:", error); 81 throw error; 82 } 83} 84 85async function buildGoLivePost( 86 text: string, 87 url: URL, 88 profile: ProfileViewDetailed, 89 params: URLSearchParams, 90 thumbnail: BlobRef | undefined, 91 agent: StreamplaceAgent, 92): Promise<AppBskyFeedPost.Record> { 93 const now = new Date(); 94 const linkUrl = `${url.protocol}//${url.host}/${profile.handle}?${params.toString()}`; 95 const prefix = `🔴 LIVE `; 96 const textUrl = `${url.protocol}//${url.host}/${profile.handle}`; 97 const suffix = ` ${text}`; 98 const content = prefix + textUrl + suffix; 99 100 const rt = new RichText({ text: content }); 101 await rt.detectFacets(agent); 102 const record: AppBskyFeedPost.Record = { 103 $type: "app.bsky.feed.post", 104 text: content, 105 "place.stream.livestream": { 106 url: linkUrl, 107 title: text, 108 }, 109 facets: rt.facets, 110 createdAt: now.toISOString(), 111 }; 112 record.embed = { 113 $type: "app.bsky.embed.external", 114 external: { 115 description: text, 116 thumb: thumbnail, 117 title: `@${profile.handle} is 🔴LIVE on ${url.host}!`, 118 uri: linkUrl, 119 }, 120 }; 121 122 return record; 123} 124 125export function useCreateStreamRecord() { 126 let agent = usePDSAgent(); 127 let url = useUrl(); 128 const uploadThumbnail = useUploadThumbnail(); 129 130 return async ({ 131 title, 132 customThumbnail, 133 submitPost, 134 customUrl, 135 }: { 136 title: string; 137 customThumbnail?: Blob; 138 submitPost?: boolean; 139 customUrl?: string | null; 140 }) => { 141 if (!submitPost) { 142 submitPost = true; 143 } 144 if (!customUrl) { 145 customUrl = null; 146 } 147 if (!agent) { 148 throw new Error("No PDS agent found"); 149 } 150 151 if (!agent.did) { 152 throw new Error("No user DID found, assuming not logged in"); 153 } 154 155 // Use customUrl if provided, otherwise fall back to the store URL 156 const finalUrl = customUrl || url; 157 const u = new URL(url); 158 159 let thumbnail: BlobRef | undefined = undefined; 160 161 if (customThumbnail) { 162 try { 163 thumbnail = await uploadThumbnail(agent, customThumbnail); 164 } catch (e) { 165 throw new Error(`Custom thumbnail upload failed ${e}`); 166 } 167 } else { 168 // No custom thumbnail: fetch the server-side image and upload it 169 // try thrice lel 170 let tries = 0; 171 try { 172 for (; tries < 3; tries++) { 173 try { 174 console.log( 175 `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, 176 ); 177 const thumbnailRes = await fetch( 178 `${u.protocol}//${u.host}/api/playback/${agent.did}/stream.png`, 179 ); 180 if (!thumbnailRes.ok) { 181 throw new Error( 182 `Failed to fetch thumbnail: ${thumbnailRes.status})`, 183 ); 184 } 185 const thumbnailBlob = await thumbnailRes.blob(); 186 console.log(thumbnailBlob); 187 thumbnail = await uploadThumbnail(agent, thumbnailBlob); 188 } catch (e) { 189 console.warn( 190 `Failed to fetch thumbnail, retrying (${tries + 1}/3): ${e}`, 191 ); 192 // Wait 1 second before retrying 193 await new Promise((resolve) => setTimeout(resolve, 2000)); 194 if (tries === 2) { 195 throw new Error(`Failed to fetch thumbnail after 3 tries: ${e}`); 196 } 197 } 198 } 199 } catch (e) { 200 throw new Error(`Thumbnail upload failed ${e}`); 201 } 202 } 203 204 let newPost: undefined | { uri: string; cid: string } = undefined; 205 206 const did = agent.did; 207 const profile = await agent.getProfile({ actor: did }); 208 209 if (submitPost) { 210 if (!profile) { 211 throw new Error("No profile found for the user DID"); 212 } 213 214 const params = new URLSearchParams({ 215 did: did, 216 time: new Date().toISOString(), 217 }); 218 219 let post = await buildGoLivePost( 220 title, 221 u, 222 profile.data, 223 params, 224 thumbnail, 225 agent, 226 ); 227 228 newPost = await createNewPost(agent, post); 229 230 if (!newPost.uri || !newPost.cid) { 231 throw new Error( 232 "Cannot read properties of undefined (reading 'uri' or 'cid')", 233 ); 234 } 235 } 236 237 let platform: string = Platform.OS; 238 let platVersion: string = Platform.Version 239 ? Platform.Version.toString() 240 : ""; 241 // no Platform.Version on web, so use browser name instead 242 if ( 243 platform === "web" && 244 typeof window !== "undefined" && 245 window.navigator 246 ) { 247 platVersion = getBrowserName(window.navigator.userAgent); 248 } 249 250 const record: PlaceStreamLivestream.Record = { 251 $type: "place.stream.livestream", 252 title: title, 253 url: finalUrl, 254 createdAt: new Date().toISOString(), 255 // would match up with e.g. https://stream.place/iame.li 256 canonicalUrl: `${finalUrl}/${profile.data.handle}`, 257 // user agent style string 258 // e.g. `@streamplace/components/0.1.0 (ios, 32.0)` 259 agent: `@streamplace/components/${PackageJson.version} (${platform}, ${platVersion})`, 260 post: newPost, 261 thumb: thumbnail, 262 }; 263 264 await agent.com.atproto.repo.createRecord({ 265 repo: agent.did, 266 collection: "place.stream.livestream", 267 record, 268 }); 269 return record; 270 }; 271} 272 273export function useUpdateStreamRecord(customUrl: string | null = null) { 274 let agent = usePDSAgent(); 275 let url = useUrl(); 276 const uploadThumbnail = useUploadThumbnail(); 277 278 return async ( 279 title: string, 280 livestream: LivestreamViewHydrated | null, 281 customThumbnail?: Blob, 282 ) => { 283 if (!agent) { 284 throw new Error("No PDS agent found"); 285 } 286 287 if (!agent.did) { 288 throw new Error("No user DID found, assuming not logged in"); 289 } 290 291 if (!livestream) { 292 throw new Error("No latest record"); 293 } 294 295 // Use customUrl if provided, otherwise fall back to the store URL 296 const finalUrl = customUrl || url; 297 298 let rkey = livestream.uri.split("/").pop(); 299 let oldRecordValue: PlaceStreamLivestream.Record = livestream.record; 300 301 if (!rkey) { 302 throw new Error("No rkey?"); 303 } 304 305 let thumbnail: BlobRef | undefined = oldRecordValue.thumb; 306 307 // update thumbnail if a new one is provided 308 if (customThumbnail) { 309 try { 310 thumbnail = await uploadThumbnail(agent, customThumbnail); 311 } catch (e) { 312 throw new Error(`Custom thumbnail upload failed ${e}`); 313 } 314 } 315 316 const record: PlaceStreamLivestream.Record = { 317 $type: "place.stream.livestream", 318 title: title, 319 url: finalUrl, 320 createdAt: new Date().toISOString(), 321 post: oldRecordValue.post, 322 thumb: thumbnail, 323 }; 324 325 await agent.com.atproto.repo.putRecord({ 326 repo: agent.did, 327 collection: "place.stream.livestream", 328 rkey, 329 record, 330 }); 331 332 return record; 333 }; 334}