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