Live video on the AT Protocol
79
fork

Configure Feed

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

at eli/optional-convergence 395 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; 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}