Live video on the AT Protocol
1import { Text, useStreamplaceStore, zero } from "@streamplace/components";
2import AQLink from "components/aqlink";
3import Container from "components/container";
4import ErrorBox from "components/error/error";
5import StreamCardHorizontal, { StreamCardSize } from "components/home/cards";
6import LiveDot from "components/home/live-dot";
7import Loading from "components/loading/loading";
8import Title from "components/title";
9import useAvatars from "hooks/useAvatars";
10import { useEffect, useState } from "react";
11import {
12 Image,
13 RefreshControl,
14 ScrollView,
15 View,
16 useWindowDimensions,
17} from "react-native";
18import { PlaceStreamLivestream } from "streamplace";
19
20// as we're not using a specific grid library these are necessary
21// to constrain the cards
22const FIRST_ROW_MAGIC_RATIO = 0.95;
23const LAST_ROW_MAGIC_RATIO = 1.16;
24
25type StreamRecord = {
26 createdAt: Date;
27 title?: string;
28 // A post announcing the stream record
29 post?: {
30 cid: string;
31 uri: string;
32 };
33 // The base URL of the streamed server
34 url: string;
35};
36
37// Function to generate mock data for testing purposes
38function generateMockSegments(count: number): {
39 streams: PlaceStreamLivestream.LivestreamView[];
40} {
41 const mockSegments: PlaceStreamLivestream.LivestreamView[] = [];
42 const baseDid = "did:plc:mockmockmockmockmockmockmockmockmock";
43
44 for (let i = 0; i < count; i++) {
45 const did = `${baseDid}${i}`;
46 const handle = `mockuser${i}`;
47 mockSegments.push({
48 uri: `at://did:plc:mockmockmockmockmockmockmockmockmock${i}/place.stream.livestream/mock${i}`,
49 cid: `bafycidmockcidmockcidmockcidmockcidmockcidmockcidm${i}`,
50 record: {
51 $type: "place.stream.livestream",
52 createdAt: new Date().toISOString(),
53 title: `Mock Stream ${i + 1}`,
54 } as PlaceStreamLivestream.Record,
55 author: {
56 did: did,
57 handle: handle,
58 },
59 indexedAt: new Date().toISOString(),
60 viewerCount: { count: Math.floor(Math.random() * 1000) },
61 });
62 }
63 return { streams: mockSegments };
64}
65
66function getHomeScreenItemSize(width: number): StreamCardSize {
67 if (width >= 1536) return "md"; // xxl
68 if (width >= 1280) return "sm"; // xl
69 if (width >= 1024) return "sm"; // lg
70 if (width >= 768) return "sm"; // md
71 return "xs"; // sm and below
72}
73
74function getHomeScreenCols(width: number): number {
75 if (width >= 1550) return 4;
76 if (width >= 1280) return 3;
77 if (width >= 1024) return 2;
78 if (width >= 768) return 2;
79 return 1;
80}
81
82// Get the ratio for the first card based on column count
83function getPadPercentage(cols: number): number {
84 if (cols >= 3) return 2.07;
85 return 1;
86}
87
88function HomeScreenItem({
89 item,
90 size,
91 avatarUrl,
92 horizontal = false,
93}: {
94 item: PlaceStreamLivestream.LivestreamView;
95 size: StreamCardSize;
96 avatarUrl?: string;
97 horizontal?: boolean;
98}) {
99 const user = item.author.handle || item.author.did;
100 return (
101 <AQLink
102 to={{
103 screen: "Stream",
104 params: {
105 user: user,
106 },
107 }}
108 style={{
109 flex: 1,
110 }}
111 >
112 <StreamCardHorizontal
113 size={size}
114 title={
115 (item.record as PlaceStreamLivestream.Record).title || "A livestream!"
116 }
117 horizontal={horizontal}
118 thumbnailUrl={`/api/playback/${user}/stream.jpg?ts=${(Date.now() / 120000).toFixed(0)}`}
119 avatarUrl={avatarUrl}
120 streamerName={user}
121 category={[]}
122 viewers={item.viewerCount?.count}
123 isLive={true}
124 />
125 </AQLink>
126 );
127}
128
129function PlaceholderItem() {
130 return (
131 <View style={[{ flex: 1 }, { opacity: 0, pointerEvents: "none" }]}>
132 <StreamCardHorizontal
133 size={"sm"}
134 title={"you found a secret :)"}
135 horizontal={false}
136 thumbnailUrl={``}
137 avatarUrl={
138 "https://cdn.bsky.app/img/avatar/plain/did:plc:4ukwiehjoytl56ysom2pdwko/bafkreieal2i74ynzrvofia6fa3efqnyxmox76ohrfldt5kvls73lbspzdm@jpeg"
139 }
140 streamerName={
141 "hi! im here to pad out the grid so it doesn't look all wacky"
142 }
143 category={[]}
144 viewers={0}
145 isLive={false}
146 />
147 </View>
148 );
149}
150
151export default function HomeScreen({
152 contentContainerStyle = {},
153}: {
154 contentContainerStyle?: any;
155}) {
156 const liveUsers = useStreamplaceStore((state) => state.liveUsers);
157 const setLiveUsers = useStreamplaceStore((state) => state.setLiveUsers);
158 const refreshLiveUsers = () => setLiveUsers({ liveUsersRefresh: Date.now() });
159 const liveUsersLoading = useStreamplaceStore(
160 (state) => state.liveUsersLoading,
161 );
162 const liveUsersError = useStreamplaceStore((state) => state.liveUsersError);
163 const [manualRefresh, setManualRefresh] = useState(false);
164 const { width } = useWindowDimensions();
165
166 // Use mock data for development/testing if needed
167 //const segments = generateMockSegments(1).streams; // Uncomment this line to use mock data
168 const segments = useStreamplaceStore((state) => state.liveUsers);
169 // const segments = realSegments; // Comment this line out if using mock data
170
171 const avis = useAvatars((segments || []).map((s) => s.author.did));
172
173 useEffect(() => {
174 if (!liveUsersLoading) {
175 setManualRefresh(false);
176 }
177 }, [liveUsersLoading]);
178
179 if (liveUsersError) {
180 if (liveUsersLoading) {
181 return <Loading />;
182 }
183 if (!segments) {
184 return <ErrorBox onRetry={refreshLiveUsers} />;
185 }
186 }
187
188 if (segments === null) {
189 // Only show loading if not using mock data and no segments yet
190 return <Loading />;
191 }
192
193 let cols = getHomeScreenCols(width);
194 let size = getHomeScreenItemSize(width);
195
196 // Only use horizontal layout for first card when we have enough columns (3+)
197 const useHorizontalFirst = cols >= 3;
198 const firstRowCols = useHorizontalFirst ? cols - 1 : cols;
199
200 const firstRowItems = segments.slice(0, firstRowCols);
201 let cutSegs = segments.slice(firstRowCols);
202
203 // fill in null data to pad out the list for grid display
204 let segs: (PlaceStreamLivestream.LivestreamView | null)[] = cutSegs.concat(
205 Array((cols - (segments.length % cols)) % cols).fill(null),
206 );
207 if (cutSegs.length === 0 && segs.every((s) => s === null) && cols > 0) {
208 // ensure segs is not just [null] if segments is empty
209 segs = [];
210 }
211
212 // assemble rows
213 const rows: (PlaceStreamLivestream.LivestreamView | null)[][] = [];
214 for (let i = 0; i < cutSegs.length; i += cols) {
215 let row = cutSegs.slice(i, i + cols);
216 // pad the last row with nulls if it's not full
217 if (i + cols >= cutSegs.length && row.length < cols) {
218 const paddingNeeded = cols - row.length;
219 row = [...row, ...Array(paddingNeeded).fill(null)];
220 }
221 rows.push(row);
222 }
223
224 return (
225 <>
226 {liveUsersError && (
227 <View>
228 <Container
229 style={{
230 backgroundColor: "#774316",
231 borderRadius: 8,
232 borderColor: "#99889988",
233 borderWidth: 2,
234 height: "auto",
235 flexDirection: "row",
236 alignItems: "center",
237 justifyContent: "flex-start",
238 paddingHorizontal: 12,
239 paddingVertical: 12,
240 gap: 12,
241 }}
242 >
243 <Text style={{ fontSize: 24, minWidth: 24, color: "white" }}>
244 ⚠️
245 </Text>
246 <Text style={{ color: "white" }}>
247 There was an error fetching the latest streams. You might be
248 offline? code: {liveUsersError || "nocode"}
249 </Text>
250 </Container>
251 </View>
252 )}
253 <ScrollView
254 style={{
255 minHeight: "80%",
256 width: "100%",
257 }}
258 contentContainerStyle={contentContainerStyle} // Apply passed contentContainerStyle
259 refreshControl={
260 <RefreshControl
261 refreshing={manualRefresh}
262 onRefresh={() => {
263 refreshLiveUsers();
264 setManualRefresh(true);
265 }}
266 />
267 }
268 >
269 <Container>
270 {segments.length > 0 && (
271 <View
272 style={[
273 { flexDirection: "row" },
274 { alignItems: "center" },
275 { gap: 12 },
276 zero.my[8],
277 zero.px[0],
278 ]}
279 >
280 <LiveDot />
281 <Title>
282 {segments.length} {segments.length === 1 ? "person" : "people"}{" "}
283 live now
284 </Title>
285 </View>
286 )}
287
288 {segments.length === 0 && (
289 <View
290 style={[
291 { flex: 1 },
292 { justifyContent: "center" },
293 { alignItems: "center" },
294 { minHeight: "auto", paddingVertical: 42 },
295 ]}
296 >
297 <Image
298 source={require("../../assets/images/jelly.png")}
299 style={{ height: 64, width: 64 }}
300 />
301 <Text
302 style={[{ fontSize: 20, fontWeight: "bold", marginTop: 12 }]}
303 >
304 No one is streaming right now
305 </Text>
306 <Text style={{ marginTop: 8 }}>Check back later?</Text>
307 </View>
308 )}
309 {firstRowItems.length > 0 && (
310 <View
311 style={[
312 { flexDirection: "row" },
313 {
314 gap: 24,
315 marginBottom: 24,
316 width: "100%",
317 },
318 ]}
319 >
320 {firstRowItems.map((item, itemIndex) => (
321 <View
322 key={item.cid || `item${itemIndex}`}
323 style={[
324 {
325 flex:
326 itemIndex == 0 && useHorizontalFirst
327 ? getPadPercentage(cols)
328 : 1,
329 },
330 { justifyContent: "center" },
331 ]}
332 >
333 <HomeScreenItem
334 item={item}
335 size={size}
336 avatarUrl={avis[item.author.did]?.avatar}
337 horizontal={itemIndex == 0 && useHorizontalFirst}
338 />
339 </View>
340 ))}
341 {/* Pad the first row to match the column count */}
342 {Array(
343 useHorizontalFirst
344 ? cols - firstRowItems.length - 1
345 : cols - firstRowItems.length,
346 )
347 .fill(null)
348 .map((_, i) => (
349 <View key={`item-${i}`} style={{ flex: 1 }}>
350 <PlaceholderItem />
351 </View>
352 ))}
353 </View>
354 )}
355
356 {segments.length > 0 && (
357 <View>
358 {rows.map((row, rowIndex) => (
359 <View
360 key={`row-${rowIndex}`}
361 style={[
362 { flexDirection: "row" },
363 { gap: 24, marginBottom: 24 },
364 ]}
365 >
366 {row.map((item, itemIndex) =>
367 item !== null ? (
368 <View
369 key={item.cid || `item-${rowIndex}-${itemIndex}`}
370 style={{ flex: 1 }}
371 >
372 <HomeScreenItem
373 item={item}
374 size={size}
375 avatarUrl={avis[item.author.did]?.avatar}
376 />
377 </View>
378 ) : (
379 <View
380 key={`item-${rowIndex}-${itemIndex}`}
381 style={{ flex: 1 }}
382 >
383 <PlaceholderItem />
384 </View>
385 ),
386 )}
387 </View>
388 ))}
389 </View>
390 )}
391 </Container>
392 </ScrollView>
393 </>
394 );
395}