Live video on the AT Protocol
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}