Live video on the AT Protocol
79
fork

Configure Feed

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

at eli/multitesting 396 lines 12 kB view raw
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}