Live video on the AT Protocol

two column card grid?

+159 -54
+159 -54
js/components/src/components/chat/teleport-modal.tsx
··· 1 + import { Check } from "lucide-react-native"; 1 2 import React, { useEffect, useMemo, useState } from "react"; 2 3 import { Image, Pressable, ScrollView, View } from "react-native"; 3 4 import { PlaceStreamLivestream } from "streamplace"; ··· 7 8 Button, 8 9 DialogFooter, 9 10 Input, 10 - MenuGroup, 11 - MenuItem, 12 11 ResponsiveDialog, 13 12 Text, 14 13 useTheme, ··· 83 82 title="Teleport to Streamer" 84 83 showCloseButton 85 84 variant="default" 86 - size="md" 85 + size="xl" 87 86 dismissible={false} 88 87 > 89 88 <View style={[zero.py[2]]}> ··· 113 112 </View> 114 113 ) : ( 115 114 <ScrollView style={[{ maxHeight: 400 }]}> 116 - <MenuGroup> 117 - {filteredStreams.map((stream) => ( 118 - <Pressable 119 - key={stream.uri} 120 - onPress={() => setSelectedStream(stream)} 121 - > 122 - <MenuItem 123 - style={ 124 - [ 125 - selectedStream?.uri === stream.uri && { 126 - backgroundColor: "rgba(0, 122, 255, 0.1)", 127 - }, 128 - zero.layout.flex.spaceBetween, 129 - zero.r.md, 130 - zero.flex[1], 131 - zero.gap.all[2], 132 - { width: "100%" }, 133 - ] as any 134 - } 115 + <View 116 + style={[ 117 + { 118 + flexDirection: "row", 119 + flexWrap: "wrap", 120 + gap: 12, 121 + }, 122 + ]} 123 + > 124 + {filteredStreams.map((stream) => { 125 + const isSelected = selectedStream?.uri === stream.uri; 126 + const profile = profiles[stream.author?.did]; 127 + 128 + return ( 129 + <Pressable 130 + key={stream.uri} 131 + onPress={() => setSelectedStream(stream)} 132 + style={[ 133 + { 134 + width: "48%", 135 + minWidth: 200, 136 + }, 137 + ]} 135 138 > 136 - <Image 137 - source={{ 138 - uri: profiles[stream.author.did]?.avatar, 139 - width: 50, 140 - height: 50, 141 - }} 142 - style={[zero.r.full]} 143 - /> 144 139 <View 145 140 style={[ 146 - zero.layout.flex.row, 147 - zero.gap.all[2], 148 - zero.layout.flex.alignCenter, 149 - { flex: 1, minWidth: 0, width: "100%" }, 141 + { 142 + backgroundColor: theme.colors.muted, 143 + borderRadius: 12, 144 + overflow: "hidden", 145 + borderWidth: 2, 146 + borderColor: isSelected 147 + ? theme.colors.primary 148 + : "transparent", 149 + }, 150 150 ]} 151 151 > 152 152 <View 153 153 style={[ 154 - zero.layout.flex.column, 155 - { flex: 1, minWidth: 0 }, 154 + { 155 + width: "100%", 156 + aspectRatio: 16 / 9, 157 + backgroundColor: theme.colors.card, 158 + position: "relative", 159 + }, 160 + ]} 161 + > 162 + <Image 163 + source={{ 164 + uri: 165 + "/api/playback/" + 166 + stream.author.did + 167 + "/stream.jpg", 168 + }} 169 + style={{ 170 + width: "100%", 171 + height: "100%", 172 + }} 173 + resizeMode="cover" 174 + /> 175 + {isSelected && ( 176 + <View 177 + style={[ 178 + { 179 + position: "absolute", 180 + top: 8, 181 + right: 8, 182 + backgroundColor: theme.colors.primary, 183 + borderRadius: 999, 184 + width: 24, 185 + height: 24, 186 + alignItems: "center", 187 + justifyContent: "center", 188 + boxShadow: "0 2px 4px rgba(0, 0, 0, 0.6)", 189 + }, 190 + ]} 191 + > 192 + <Check size={16} color="white" /> 193 + </View> 194 + )} 195 + {stream.viewerCount && ( 196 + <View 197 + style={[ 198 + { 199 + position: "absolute", 200 + top: 8, 201 + left: 8, 202 + backgroundColor: "rgba(0, 0, 0, 0.75)", 203 + borderRadius: 999, 204 + paddingHorizontal: 8, 205 + paddingVertical: 4, 206 + }, 207 + ]} 208 + > 209 + <Text style={[{ fontSize: 12, color: "white" }]}> 210 + {stream.viewerCount.count} viewer 211 + {stream.viewerCount.count !== 1 ? "s" : ""} 212 + </Text> 213 + </View> 214 + )} 215 + </View> 216 + <View 217 + style={[ 218 + { 219 + padding: 12, 220 + flexDirection: "row", 221 + gap: 8, 222 + alignItems: "center", 223 + }, 156 224 ]} 157 225 > 158 - <Text numberOfLines={1} ellipsizeMode="tail"> 159 - {stream.author?.handle} 160 - </Text> 161 - {stream.record.title ? ( 226 + <View 227 + style={[ 228 + { 229 + width: 40, 230 + height: 40, 231 + borderRadius: 20, 232 + overflow: "hidden", 233 + backgroundColor: theme.colors.card, 234 + flexShrink: 0, 235 + }, 236 + ]} 237 + > 238 + {profile?.avatar ? ( 239 + <Image 240 + source={{ 241 + uri: profile.avatar, 242 + }} 243 + style={{ width: "100%", height: "100%" }} 244 + resizeMode="cover" 245 + /> 246 + ) : ( 247 + <View 248 + style={{ 249 + width: "100%", 250 + height: "100%", 251 + backgroundColor: theme.colors.muted, 252 + }} 253 + /> 254 + )} 255 + </View> 256 + 257 + {/* Text */} 258 + <View style={[{ flex: 1, minWidth: 0 }]}> 162 259 <Text 163 - color="muted" 260 + numberOfLines={1} 164 261 ellipsizeMode="tail" 165 - numberOfLines={1} 262 + style={[{ fontWeight: "600" }]} 166 263 > 167 - {(stream.record.title as any) || ""} 264 + {stream.author?.handle} 168 265 </Text> 169 - ) : null} 170 - {stream.viewerCount && ( 171 - <Text color="muted"> 172 - {stream.viewerCount.count} viewer 173 - {stream.viewerCount.count !== 1 ? "s" : ""} 174 - </Text> 175 - )} 266 + {stream.record.title ? ( 267 + <Text 268 + numberOfLines={1} 269 + ellipsizeMode="tail" 270 + style={[ 271 + { 272 + fontSize: 12, 273 + color: theme.colors.textMuted, 274 + }, 275 + ]} 276 + > 277 + {stream.record.title as any} 278 + </Text> 279 + ) : null} 280 + </View> 176 281 </View> 177 282 </View> 178 - </MenuItem> 179 - </Pressable> 180 - ))} 181 - </MenuGroup> 283 + </Pressable> 284 + ); 285 + })} 286 + </View> 182 287 </ScrollView> 183 288 )} 184 289 </View>