Live video on the AT Protocol

add a portal host inside video, and hook it up to the dropdown

+305 -278
+113 -107
js/app/components/mobile/desktop-ui.tsx
··· 1 import { 2 PlayerUI, 3 Toast, 4 useLivestreamInfo, 5 useOffline, ··· 79 const [pipActive, setPipActive] = useState(false); 80 const fadeOpacity = useSharedValue(1); 81 const fadeTimeout = useRef<NodeJS.Timeout | null>(null); 82 - const FADE_OUT_DELAY = 500; 83 84 const isSelfAndNotLive = ingest === "new"; 85 const isActivelyLive = ingest !== null && ingest !== "new"; ··· 161 162 const hover = Gesture.Hover().onChange((_) => runOnJS(onPlayerHover)()); 163 164 return ( 165 - <GestureDetector gesture={hover}> 166 - <View 167 - style={[layout.position.absolute, h.percent[100], w.percent[100]]} 168 - collapsable={false} 169 - > 170 - <MuteOverlay /> 171 - <PlayerUI.AutoplayButton /> 172 - <PlayerUI.ViewerLoadingOverlay /> 173 - <Animated.View 174 - style={[ 175 - layout.position.absolute, 176 - w.percent[100], 177 - { 178 - top: safeAreaInsets.top, 179 - paddingHorizontal: 16, 180 - paddingVertical: 16, 181 - }, 182 - animatedFadeStyle, 183 - ]} 184 > 185 - <TopControlBar 186 - offline={offline} 187 - isActivelyLive={isActivelyLive} 188 - ingest={ingest} 189 - isChatOpen={isChatOpen || false} 190 - onToggleChat={toggleChat} 191 - embedded={embedded} 192 - /> 193 - </Animated.View> 194 - 195 - {isActivelyLive && isControlsVisible && ( 196 - <View 197 style={[ 198 layout.position.absolute, 199 { 200 - transform: [{ translateX: -100 }, { translateY: -25 }], 201 }, 202 ]} 203 > 204 - <Animated.View 205 style={[ 206 { 207 - padding: 12, 208 - backgroundColor: "rgba(0, 0, 0, 0.5)", 209 }, 210 - r[3], 211 - animatedFadeStyle, 212 ]} 213 > 214 - <PlayerUI.MetricsPanel showMetrics={isActivelyLive} /> 215 - </Animated.View> 216 - </View> 217 - )} 218 - 219 - <Animated.View 220 - style={[ 221 - layout.position.absolute, 222 - position.bottom[0], 223 - w.percent[100], 224 - { 225 - backgroundColor: "rgba(0, 0, 0, 0.6)", 226 - paddingHorizontal: 16, 227 - paddingVertical: 2, 228 - paddingBottom: 2, 229 - }, 230 - animatedFadeStyle, 231 - ]} 232 - > 233 - <BottomControlBar 234 - ingest={ingest} 235 - pipSupported={pipSupported} 236 - pipActive={pipActive} 237 - onHandlePip={handlePip} 238 - dropdownPortalContainer={dropdownPortalContainer} 239 - showChat={isChatOpen || false} 240 - setShowChat={setIsChatOpen || undefined} 241 - /> 242 - </Animated.View> 243 - 244 - {isSelfAndNotLive && ( 245 - <PlayerUI.InputPanel 246 - title={title} 247 - setTitle={setTitle} 248 - ingestStarting={ingestStarting} 249 - toggleGoLive={toggleGoLive} 250 - /> 251 - )} 252 - 253 - <PlayerUI.CountdownOverlay 254 - visible={showCountdown} 255 - width={width} 256 - height={height} 257 - onDone={() => { 258 - setShowCountdown(false); 259 - }} 260 - /> 261 262 - <Toast 263 - open={recordSubmitted} 264 - onOpenChange={setRecordSubmitted} 265 - title="You're live!" 266 - description="We're notifying your followers that you just went live." 267 - duration={5} 268 - /> 269 - {showMetrics && ( 270 - <View 271 style={[ 272 layout.position.absolute, 273 - position.top[20], 274 - position.left[4], 275 - px[4], 276 - py[2], 277 { 278 - backgroundColor: "rgba(0, 0, 0, 0.7)", 279 - borderRadius: 8, 280 - borderWidth: 1, 281 - borderColor: "#374151", 282 }, 283 ]} 284 > 285 - <PlayerUI.MetricsPanel showMetrics={showMetrics} /> 286 - </View> 287 - )} 288 - </View> 289 - </GestureDetector> 290 ); 291 }
··· 1 import { 2 PlayerUI, 3 + PortalHost, 4 Toast, 5 useLivestreamInfo, 6 useOffline, ··· 80 const [pipActive, setPipActive] = useState(false); 81 const fadeOpacity = useSharedValue(1); 82 const fadeTimeout = useRef<NodeJS.Timeout | null>(null); 83 + const FADE_OUT_DELAY = 2500; 84 85 const isSelfAndNotLive = ingest === "new"; 86 const isActivelyLive = ingest !== null && ingest !== "new"; ··· 162 163 const hover = Gesture.Hover().onChange((_) => runOnJS(onPlayerHover)()); 164 165 + const portalContainerID = "epicportal123"; 166 + 167 return ( 168 + <> 169 + <GestureDetector gesture={hover}> 170 + <View 171 + style={[layout.position.absolute, h.percent[100], w.percent[100]]} 172 + collapsable={false} 173 > 174 + <MuteOverlay /> 175 + <PlayerUI.AutoplayButton /> 176 + <PlayerUI.ViewerLoadingOverlay /> 177 + <Animated.View 178 style={[ 179 layout.position.absolute, 180 + w.percent[100], 181 { 182 + top: safeAreaInsets.top, 183 + paddingHorizontal: 16, 184 + paddingVertical: 16, 185 }, 186 + animatedFadeStyle, 187 ]} 188 > 189 + <TopControlBar 190 + offline={offline} 191 + isActivelyLive={isActivelyLive} 192 + ingest={ingest} 193 + isChatOpen={isChatOpen || false} 194 + onToggleChat={toggleChat} 195 + embedded={embedded} 196 + /> 197 + </Animated.View> 198 + 199 + {isActivelyLive && isControlsVisible && ( 200 + <View 201 style={[ 202 + layout.position.absolute, 203 { 204 + transform: [{ translateX: -100 }, { translateY: -25 }], 205 }, 206 ]} 207 > 208 + <Animated.View 209 + style={[ 210 + { 211 + padding: 12, 212 + backgroundColor: "rgba(0, 0, 0, 0.5)", 213 + }, 214 + r[3], 215 + animatedFadeStyle, 216 + ]} 217 + > 218 + <PlayerUI.MetricsPanel showMetrics={isActivelyLive} /> 219 + </Animated.View> 220 + </View> 221 + )} 222 223 + <Animated.View 224 style={[ 225 layout.position.absolute, 226 + position.bottom[0], 227 + w.percent[100], 228 { 229 + backgroundColor: "rgba(0, 0, 0, 0.6)", 230 + paddingHorizontal: 16, 231 + paddingVertical: 2, 232 + paddingBottom: 2, 233 }, 234 + animatedFadeStyle, 235 ]} 236 > 237 + <BottomControlBar 238 + ingest={ingest} 239 + pipSupported={pipSupported} 240 + pipActive={pipActive} 241 + onHandlePip={handlePip} 242 + dropdownPortalContainer={portalContainerID} 243 + showChat={isChatOpen || false} 244 + setShowChat={setIsChatOpen || undefined} 245 + /> 246 + </Animated.View> 247 + 248 + {isSelfAndNotLive && ( 249 + <PlayerUI.InputPanel 250 + title={title} 251 + setTitle={setTitle} 252 + ingestStarting={ingestStarting} 253 + toggleGoLive={toggleGoLive} 254 + /> 255 + )} 256 + 257 + <PlayerUI.CountdownOverlay 258 + visible={showCountdown} 259 + width={width} 260 + height={height} 261 + onDone={() => { 262 + setShowCountdown(false); 263 + }} 264 + /> 265 + 266 + <Toast 267 + open={recordSubmitted} 268 + onOpenChange={setRecordSubmitted} 269 + title="You're live!" 270 + description="We're notifying your followers that you just went live." 271 + duration={5} 272 + /> 273 + {showMetrics && ( 274 + <View 275 + style={[ 276 + layout.position.absolute, 277 + position.top[20], 278 + position.left[4], 279 + px[4], 280 + py[2], 281 + { 282 + backgroundColor: "rgba(0, 0, 0, 0.7)", 283 + borderRadius: 8, 284 + borderWidth: 1, 285 + borderColor: "#374151", 286 + }, 287 + ]} 288 + > 289 + <PlayerUI.MetricsPanel showMetrics={showMetrics} /> 290 + </View> 291 + )} 292 + </View> 293 + </GestureDetector> 294 + <PortalHost name="fullscreenepic" /> 295 + </> 296 ); 297 }
+5 -5
js/app/components/mobile/desktop-ui/bottom-controls.tsx
··· 83 /> 84 </Pressable> 85 )} 86 {Platform.OS === "web" && ( 87 <Pressable 88 onPress={() => { ··· 96 <Fullscreen color={theme.colors.text} /> 97 )} 98 </Pressable> 99 - )} 100 - {ingest === null && ( 101 - <PlayerUI.ContextMenu 102 - dropdownPortalContainer={dropdownPortalContainer} 103 - /> 104 )} 105 {/* if not web, then add the collapse chat controls here */} 106 {Platform.OS !== "web" && setShowChat && (
··· 83 /> 84 </Pressable> 85 )} 86 + {ingest === null && ( 87 + <PlayerUI.ContextMenu 88 + dropdownPortalContainer={dropdownPortalContainer} 89 + /> 90 + )} 91 {Platform.OS === "web" && ( 92 <Pressable 93 onPress={() => { ··· 101 <Fullscreen color={theme.colors.text} /> 102 )} 103 </Pressable> 104 )} 105 {/* if not web, then add the collapse chat controls here */} 106 {Platform.OS !== "web" && setShowChat && (
+187 -166
js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
··· 1 import { useRootContext } from "@rn-primitives/dropdown-menu"; 2 - import { Menu } from "lucide-react-native"; 3 import { Image, Linking, Platform, Pressable, View } from "react-native"; 4 import { 5 ContentRights, 6 ContentWarnings, ··· 10 useLivestreamInfo, 11 zero, 12 } from "../../.."; 13 - import { colors } from "../../../lib/theme"; 14 import { useLivestreamStore } from "../../../livestream-store"; 15 import { PlayerProtocol, usePlayerStore } from "../../../player-store/"; 16 import { useGraphManager } from "../../../streamplace-store/graph"; 17 - import { gap, pt, px } from "../../../ui"; 18 import { 19 DropdownMenu, 20 DropdownMenuCheckboxItem, 21 DropdownMenuGroup, 22 DropdownMenuInfo, 23 DropdownMenuItem, 24 - DropdownMenuPortal, 25 DropdownMenuRadioGroup, 26 DropdownMenuRadioItem, 27 DropdownMenuSeparator, ··· 31 DropdownMenuTrigger, 32 ResponsiveDropdownMenuContent, 33 Text, 34 } from "../../ui"; 35 36 export function ContextMenu({ ··· 38 }: { 39 dropdownPortalContainer?: any; 40 }) { 41 const quality = usePlayerStore((x) => x.selectedRendition); 42 const setQuality = usePlayerStore((x) => x.setSelectedRendition); 43 const qualities = useLivestreamStore((x) => x.renditions); ··· 58 const ls = useLivestreamStore((x) => x.livestream); 59 const segment = useLivestreamStore((x) => x.segment); 60 61 // Get content rights from the latest segment 62 const contentRights = segment?.contentRights; 63 const contentWarnings = segment?.contentWarnings?.warnings || []; ··· 73 const isMobile = Platform.OS === "ios" || Platform.OS === "android"; 74 75 // dummy portal for mobile 76 - const Portal = isMobile ? View : DropdownMenuPortal; 77 78 const DropdownMenuContent = ResponsiveDropdownMenuContent; 79 80 return ( 81 - <DropdownMenu> 82 <DropdownMenuTrigger> 83 - <Menu color={colors.gray[200]} /> 84 </DropdownMenuTrigger> 85 - <Portal container={dropdownPortalContainer}> 86 - <DropdownMenuContent side="top" align="end"> 87 - {Platform.OS !== "web" && ( 88 - <DropdownMenuGroup title="Streamer"> 89 - <View 90 - style={[ 91 - zero.layout.flex.row, 92 - zero.layout.flex.center, 93 - zero.gap.all[3], 94 - { flex: 1, minWidth: 0 }, 95 - ]} 96 - > 97 - {profile?.did && avatars[profile?.did]?.avatar && ( 98 - <Image 99 - key="avatar" 100 - source={{ 101 - uri: avatars[profile?.did]?.avatar, 102 }} 103 - style={{ width: 42, height: 42, borderRadius: 999 }} 104 - resizeMode="cover" 105 - /> 106 - )} 107 - <View style={{ flex: 1, minWidth: 0 }}> 108 - <View 109 - style={[ 110 - zero.layout.flex.row, 111 - zero.layout.flex.alignCenter, 112 - zero.gap.all[2], 113 - ]} 114 > 115 - <Pressable 116 - onPress={() => { 117 - if (profile?.handle) { 118 - const url = `https://bsky.app/profile/${formatHandle(profile)}`; 119 - Linking.openURL(url); 120 - } 121 - }} 122 - > 123 - <Text>{profile && formatHandleWithAt(profile)}</Text> 124 - </Pressable> 125 - {/*{did && profile && ( 126 <FollowButton streamerDID={profile?.did} currentUserDID={did} /> 127 )}*/} 128 - </View> 129 - <Text 130 - color="muted" 131 - size="sm" 132 - numberOfLines={2} 133 - ellipsizeMode="tail" 134 - > 135 - {ls?.record.title || "Stream Title"} 136 - </Text> 137 </View> 138 - </View> 139 - <DropdownMenuSeparator /> 140 - <DropdownMenuItem 141 - disabled={graphManager.isLoading || !profile?.did} 142 - onPress={async () => { 143 - try { 144 - if (graphManager.isFollowing) { 145 - await graphManager.unfollow(); 146 - } else { 147 - await graphManager.follow(); 148 - } 149 - } catch (err) { 150 - console.error("Follow/unfollow error:", err); 151 - } 152 - }} 153 - > 154 <Text 155 - color={graphManager.isFollowing ? "destructive" : "default"} 156 > 157 - {graphManager.isLoading 158 - ? "Loading..." 159 - : graphManager.isFollowing 160 - ? "Unfollow" 161 - : "Follow"} 162 </Text> 163 - </DropdownMenuItem> 164 - <DropdownMenuSeparator /> 165 - <DropdownMenuItem 166 - onPress={() => { 167 - if (profile?.handle) { 168 - const url = `https://bsky.app/profile/${formatHandle(profile)}`; 169 - Linking.openURL(url); 170 } 171 - }} 172 > 173 - <Text>View Profile on Bluesky</Text> 174 - </DropdownMenuItem> 175 - </DropdownMenuGroup> 176 - )} 177 - 178 - <DropdownMenuGroup> 179 - <DropdownMenuSub> 180 - <DropdownMenuSubTrigger subMenuTitle="Quality"> 181 - <View 182 - style={[ 183 - zero.flex.values[1], 184 - isMobile ? zero.layout.flex.row : zero.layout.flex.column, 185 - zero.layout.flex.spaceBetween, 186 - zero.pr[4], 187 - ]} 188 - > 189 - <Text>Quality</Text> 190 - <Text muted size={isMobile ? "base" : "sm"}> 191 - {quality === "source" ? "Source" : quality},{" "} 192 - {lowLatency ? "Low Latency" : ""} 193 - </Text> 194 - </View> 195 - </DropdownMenuSubTrigger> 196 - <DropdownMenuSubContent> 197 - <DropdownMenuGroup title="Resolution"> 198 - <DropdownMenuRadioGroup 199 - value={quality} 200 - onValueChange={setQuality} 201 - > 202 - <DropdownMenuRadioItem value="source"> 203 - <Text>Source (Original Quality)</Text> 204 - </DropdownMenuRadioItem> 205 - {qualities.map((r) => ( 206 - <DropdownMenuRadioItem key={r.name} value={r.name}> 207 - <Text>{r.name}</Text> 208 - </DropdownMenuRadioItem> 209 - ))} 210 - </DropdownMenuRadioGroup> 211 - </DropdownMenuGroup> 212 - <DropdownMenuGroup> 213 - <DropdownMenuCheckboxItem 214 - checked={lowLatency} 215 - onCheckedChange={() => setLowLatency(!lowLatency)} 216 - > 217 - <Text>Low Latency</Text> 218 - </DropdownMenuCheckboxItem> 219 - </DropdownMenuGroup> 220 - <DropdownMenuInfo description="Reduces the delay between video and chat for a more real-time experience." /> 221 - </DropdownMenuSubContent> 222 - </DropdownMenuSub> 223 - </DropdownMenuGroup> 224 - <DropdownMenuGroup title="Advanced"> 225 - <DropdownMenuCheckboxItem 226 - checked={debugInfo} 227 - onCheckedChange={() => setShowDebugInfo(!debugInfo)} 228 > 229 - <Text>Show Debug Info</Text> 230 - </DropdownMenuCheckboxItem> 231 - </DropdownMenuGroup> 232 - <DropdownMenuGroup title="Report"> 233 - <ReportButton 234 - livestream={livestream} 235 - setReportModalOpen={setReportModalOpen} 236 - setReportSubject={setReportSubject} 237 - /> 238 </DropdownMenuGroup> 239 - <View style={[pt[3], px[2], gap.all[2]]}> 240 - {contentWarnings && contentWarnings.length > 0 && ( 241 - <View style={[gap.all[1]]}> 242 - <Text size="base" color="muted"> 243 - Stream may contain 244 </Text> 245 - <ContentWarnings warnings={contentWarnings} compact={true} /> 246 </View> 247 - )} 248 - {contentRights && Object.keys(contentRights).length > 0 && ( 249 - <ContentRights 250 - contentRights={contentRights} 251 - size="xs" 252 - color="muted" 253 - /> 254 - )} 255 - </View> 256 - </DropdownMenuContent> 257 - </Portal> 258 </DropdownMenu> 259 ); 260 }
··· 1 import { useRootContext } from "@rn-primitives/dropdown-menu"; 2 + import { Cog } from "lucide-react-native"; 3 + import { useState } from "react"; 4 import { Image, Linking, Platform, Pressable, View } from "react-native"; 5 + import Animated, { 6 + Easing, 7 + useAnimatedStyle, 8 + withTiming, 9 + } from "react-native-reanimated"; 10 import { 11 ContentRights, 12 ContentWarnings, ··· 16 useLivestreamInfo, 17 zero, 18 } from "../../.."; 19 import { useLivestreamStore } from "../../../livestream-store"; 20 import { PlayerProtocol, usePlayerStore } from "../../../player-store/"; 21 import { useGraphManager } from "../../../streamplace-store/graph"; 22 + import { gap, p, pt, px } from "../../../ui"; 23 import { 24 DropdownMenu, 25 DropdownMenuCheckboxItem, 26 DropdownMenuGroup, 27 DropdownMenuInfo, 28 DropdownMenuItem, 29 DropdownMenuRadioGroup, 30 DropdownMenuRadioItem, 31 DropdownMenuSeparator, ··· 35 DropdownMenuTrigger, 36 ResponsiveDropdownMenuContent, 37 Text, 38 + useTheme, 39 } from "../../ui"; 40 41 export function ContextMenu({ ··· 43 }: { 44 dropdownPortalContainer?: any; 45 }) { 46 + const th = useTheme(); 47 const quality = usePlayerStore((x) => x.selectedRendition); 48 const setQuality = usePlayerStore((x) => x.setSelectedRendition); 49 const qualities = useLivestreamStore((x) => x.renditions); ··· 64 const ls = useLivestreamStore((x) => x.livestream); 65 const segment = useLivestreamStore((x) => x.segment); 66 67 + const [isOpen, setIsOpen] = useState(false); 68 + 69 // Get content rights from the latest segment 70 const contentRights = segment?.contentRights; 71 const contentWarnings = segment?.contentWarnings?.warnings || []; ··· 81 const isMobile = Platform.OS === "ios" || Platform.OS === "android"; 82 83 // dummy portal for mobile 84 + //const Portal: typeof DropdownMenuPortal = DropdownMenu; 85 86 const DropdownMenuContent = ResponsiveDropdownMenuContent; 87 88 + const iconRotate = useAnimatedStyle(() => { 89 + return { 90 + transform: [ 91 + { 92 + rotateZ: withTiming(isOpen ? "240deg" : "0deg", { 93 + duration: 650, 94 + easing: Easing.out(Easing.ease), 95 + }), 96 + }, 97 + ], 98 + }; 99 + }); 100 + 101 return ( 102 + <DropdownMenu onOpenChange={setIsOpen}> 103 <DropdownMenuTrigger> 104 + <Animated.View style={[p[2], iconRotate]}> 105 + <Cog color={th.theme.colors.foreground} /> 106 + </Animated.View> 107 </DropdownMenuTrigger> 108 + <DropdownMenuContent side="top" align="end" portalHost="fullscreenepic"> 109 + {Platform.OS !== "web" && ( 110 + <DropdownMenuGroup title="Streamer"> 111 + <View 112 + style={[ 113 + zero.layout.flex.row, 114 + zero.layout.flex.center, 115 + zero.gap.all[3], 116 + { flex: 1, minWidth: 0 }, 117 + ]} 118 + > 119 + {profile?.did && avatars[profile?.did]?.avatar && ( 120 + <Image 121 + key="avatar" 122 + source={{ 123 + uri: avatars[profile?.did]?.avatar, 124 + }} 125 + style={{ width: 42, height: 42, borderRadius: 999 }} 126 + resizeMode="cover" 127 + /> 128 + )} 129 + <View style={{ flex: 1, minWidth: 0 }}> 130 + <View 131 + style={[ 132 + zero.layout.flex.row, 133 + zero.layout.flex.alignCenter, 134 + zero.gap.all[2], 135 + ]} 136 + > 137 + <Pressable 138 + onPress={() => { 139 + if (profile?.handle) { 140 + const url = `https://bsky.app/profile/${formatHandle(profile)}`; 141 + Linking.openURL(url); 142 + } 143 }} 144 > 145 + <Text>{profile && formatHandleWithAt(profile)}</Text> 146 + </Pressable> 147 + {/*{did && profile && ( 148 <FollowButton streamerDID={profile?.did} currentUserDID={did} /> 149 )}*/} 150 </View> 151 <Text 152 + color="muted" 153 + size="sm" 154 + numberOfLines={2} 155 + ellipsizeMode="tail" 156 > 157 + {ls?.record.title || "Stream Title"} 158 </Text> 159 + </View> 160 + </View> 161 + <DropdownMenuSeparator /> 162 + <DropdownMenuItem 163 + disabled={graphManager.isLoading || !profile?.did} 164 + onPress={async () => { 165 + try { 166 + if (graphManager.isFollowing) { 167 + await graphManager.unfollow(); 168 + } else { 169 + await graphManager.follow(); 170 } 171 + } catch (err) { 172 + console.error("Follow/unfollow error:", err); 173 + } 174 + }} 175 + > 176 + <Text 177 + color={graphManager.isFollowing ? "destructive" : "default"} 178 > 179 + {graphManager.isLoading 180 + ? "Loading..." 181 + : graphManager.isFollowing 182 + ? "Unfollow" 183 + : "Follow"} 184 + </Text> 185 + </DropdownMenuItem> 186 + <DropdownMenuSeparator /> 187 + <DropdownMenuItem 188 + onPress={() => { 189 + if (profile?.handle) { 190 + const url = `https://bsky.app/profile/${formatHandle(profile)}`; 191 + Linking.openURL(url); 192 + } 193 + }} 194 > 195 + <Text>View Profile on Bluesky</Text> 196 + </DropdownMenuItem> 197 </DropdownMenuGroup> 198 + )} 199 + 200 + <DropdownMenuGroup> 201 + <DropdownMenuSub> 202 + <DropdownMenuSubTrigger subMenuTitle="Quality"> 203 + <View 204 + style={[ 205 + zero.flex.values[1], 206 + isMobile ? zero.layout.flex.row : zero.layout.flex.column, 207 + zero.layout.flex.spaceBetween, 208 + zero.pr[4], 209 + ]} 210 + > 211 + <Text>Quality</Text> 212 + <Text muted size={isMobile ? "base" : "sm"}> 213 + {quality === "source" ? "Source" : quality},{" "} 214 + {lowLatency ? "Low Latency" : ""} 215 </Text> 216 </View> 217 + </DropdownMenuSubTrigger> 218 + <DropdownMenuSubContent> 219 + <DropdownMenuGroup title="Resolution"> 220 + <DropdownMenuRadioGroup 221 + value={quality} 222 + onValueChange={setQuality} 223 + > 224 + <DropdownMenuRadioItem value="source"> 225 + <Text>Source (Original Quality)</Text> 226 + </DropdownMenuRadioItem> 227 + {qualities.map((r) => ( 228 + <DropdownMenuRadioItem key={r.name} value={r.name}> 229 + <Text>{r.name}</Text> 230 + </DropdownMenuRadioItem> 231 + ))} 232 + </DropdownMenuRadioGroup> 233 + </DropdownMenuGroup> 234 + <DropdownMenuGroup> 235 + <DropdownMenuCheckboxItem 236 + checked={lowLatency} 237 + onCheckedChange={() => setLowLatency(!lowLatency)} 238 + > 239 + <Text>Low Latency</Text> 240 + </DropdownMenuCheckboxItem> 241 + </DropdownMenuGroup> 242 + <DropdownMenuInfo description="Reduces the delay between video and chat for a more real-time experience." /> 243 + </DropdownMenuSubContent> 244 + </DropdownMenuSub> 245 + </DropdownMenuGroup> 246 + <DropdownMenuGroup title="Advanced"> 247 + <DropdownMenuCheckboxItem 248 + checked={debugInfo} 249 + onCheckedChange={() => setShowDebugInfo(!debugInfo)} 250 + > 251 + <Text>Show Debug Info</Text> 252 + </DropdownMenuCheckboxItem> 253 + </DropdownMenuGroup> 254 + <DropdownMenuGroup title="Report"> 255 + <ReportButton 256 + livestream={livestream} 257 + setReportModalOpen={setReportModalOpen} 258 + setReportSubject={setReportSubject} 259 + /> 260 + </DropdownMenuGroup> 261 + <View style={[pt[3], px[2], gap.all[2]]}> 262 + {contentWarnings && contentWarnings.length > 0 && ( 263 + <View style={[gap.all[1]]}> 264 + <Text size="base" color="muted"> 265 + Stream may contain 266 + </Text> 267 + <ContentWarnings warnings={contentWarnings} compact={true} /> 268 + </View> 269 + )} 270 + {contentRights && Object.keys(contentRights).length > 0 && ( 271 + <ContentRights 272 + contentRights={contentRights} 273 + size="xs" 274 + color="muted" 275 + /> 276 + )} 277 + </View> 278 + </DropdownMenuContent> 279 </DropdownMenu> 280 ); 281 }
js/components/src/components/ui/portal.native.tsx js/components/src/components/ui/portal.tsx