Live video on the AT Protocol
79
fork

Configure Feed

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

at eli/version-label 310 lines 8.6 kB view raw
1import { 2 PlayerUI, 3 Toast, 4 useLivestreamInfo, 5 useOffline, 6 usePlayerDimensions, 7 usePlayerStore, 8 useSegment, 9 View, 10 zero, 11} from "@streamplace/components"; 12import React, { useCallback, useEffect, useRef, useState } from "react"; 13import { Platform } from "react-native"; 14import { Gesture, GestureDetector } from "react-native-gesture-handler"; 15import Animated, { 16 runOnJS, 17 useAnimatedStyle, 18 useSharedValue, 19 withTiming, 20} from "react-native-reanimated"; 21import { 22 BottomControlBar, 23 MuteOverlay, 24 TopControlBar, 25} from "./desktop-ui/index"; 26import { useResponsiveLayout } from "./useResponsiveLayout"; 27 28const { h, layout, position, w, px, py, r, p } = zero; 29 30function isRefObject( 31 ref: any, 32): ref is 33 | React.RefObject<HTMLVideoElement> 34 | React.MutableRefObject<HTMLVideoElement | null> { 35 return ref && typeof ref === "object" && "current" in ref; 36} 37 38export function DesktopUi({ 39 dropdownPortalContainer, 40}: { 41 dropdownPortalContainer?: any; 42}) { 43 const { 44 ingest, 45 title, 46 setTitle, 47 showCountdown, 48 setShowCountdown, 49 recordSubmitted, 50 setRecordSubmitted, 51 ingestStarting, 52 setIngestStarting, 53 toggleGoLive, 54 } = useLivestreamInfo(); 55 const { width, height } = usePlayerDimensions(); 56 const { safeAreaInsets, shouldShowFloatingMetrics } = useResponsiveLayout(); 57 58 const offline = useOffline(); 59 const showMetrics = usePlayerStore((state) => state.showDebugInfo); 60 const pipAction = usePlayerStore((state) => state.pipAction); 61 const videoRef = usePlayerStore((state) => state.videoRef); 62 63 const segment = useSegment(); 64 65 const [isControlsVisible, setIsControlsVisible] = useState(true); 66 const [isChatOpen, setIsChatOpen] = useState(false); 67 const [pipSupported, setPipSupported] = useState(false); 68 const [pipActive, setPipActive] = useState(false); 69 const fadeOpacity = useSharedValue(1); 70 const fadeTimeout = useRef<NodeJS.Timeout | null>(null); 71 const FADE_OUT_DELAY = 500; 72 73 const isSelfAndNotLive = ingest === "new"; 74 const isActivelyLive = ingest !== null && ingest !== "new"; 75 76 const resetFadeTimer = useCallback(() => { 77 fadeOpacity.value = withTiming(1, { duration: 200 }); 78 if (fadeTimeout.current) clearTimeout(fadeTimeout.current); 79 setIsControlsVisible(true); 80 81 fadeTimeout.current = setTimeout(() => { 82 fadeOpacity.value = withTiming(0, { duration: 400 }); 83 setIsControlsVisible(false); 84 }, FADE_OUT_DELAY); 85 }, [fadeOpacity]); 86 87 const onPlayerHover = useCallback(() => { 88 resetFadeTimer(); 89 }, [resetFadeTimer]); 90 91 const toggleChat = useCallback(() => { 92 setIsChatOpen((prev) => !prev); 93 }, []); 94 95 useEffect(() => { 96 resetFadeTimer(); 97 98 return () => { 99 if (fadeTimeout.current) clearTimeout(fadeTimeout.current); 100 if (ingestStarting) { 101 setIngestStarting(false); 102 } 103 }; 104 }, [ingestStarting, setIngestStarting, resetFadeTimer]); 105 106 const animatedFadeStyle = useAnimatedStyle(() => ({ 107 opacity: shouldShowFloatingMetrics ? 1 : fadeOpacity.value, 108 })); 109 110 // Picture-in-Picture support detection 111 useEffect(() => { 112 if (Platform.OS === "web") { 113 setPipSupported( 114 !!document.pictureInPictureEnabled && pipAction !== undefined, 115 ); 116 } 117 }, [pipAction]); 118 119 // Picture-in-Picture event listeners 120 useEffect(() => { 121 if (Platform.OS !== "web") return; 122 123 let video: HTMLVideoElement | null = null; 124 if (isRefObject(videoRef)) { 125 video = videoRef.current; 126 } 127 if (!video) return; 128 129 function onEnter() { 130 setPipActive(true); 131 } 132 function onLeave() { 133 setPipActive(false); 134 } 135 136 video.addEventListener("enterpictureinpicture", onEnter); 137 video.addEventListener("leavepictureinpicture", onLeave); 138 139 return () => { 140 if (video) { 141 video.removeEventListener("enterpictureinpicture", onEnter); 142 video.removeEventListener("leavepictureinpicture", onLeave); 143 } 144 }; 145 }, [videoRef]); 146 147 const handlePip = useCallback(() => { 148 if (pipAction) pipAction(); 149 }, [pipAction]); 150 151 // Live timer for offline overlay 152 const [timeSinceLastSeen, setTimeSinceLastSeen] = useState("Unknown"); 153 154 useEffect(() => { 155 if (!offline || !segment?.startTime) { 156 setTimeSinceLastSeen("Unknown"); 157 return; 158 } 159 160 const updateTimer = () => { 161 const now = new Date(); 162 const lastSeen = new Date(segment.startTime); 163 const diffMs = now.getTime() - lastSeen.getTime(); 164 const diffMinutes = Math.floor(diffMs / 60000); 165 const diffSeconds = Math.floor((diffMs % 60000) / 1000); 166 167 if (diffMinutes > 0) { 168 setTimeSinceLastSeen(`${diffMinutes}m ${diffSeconds}s ago`); 169 } else { 170 setTimeSinceLastSeen(`${diffSeconds}s ago`); 171 } 172 }; 173 174 // Update immediately 175 updateTimer(); 176 177 // Update every second while offline 178 const interval = setInterval(updateTimer, 1000); 179 180 return () => clearInterval(interval); 181 }, [offline, segment?.startTime]); 182 183 const hover = Gesture.Hover().onChange((_) => runOnJS(onPlayerHover)()); 184 185 return ( 186 <GestureDetector gesture={hover}> 187 <> 188 <View 189 style={[layout.position.absolute, h.percent[100], w.percent[100]]} 190 > 191 <MuteOverlay /> 192 <PlayerUI.ViewerLoadingOverlay /> 193 <Animated.View 194 style={[ 195 layout.position.absolute, 196 w.percent[100], 197 { 198 top: safeAreaInsets.top, 199 paddingHorizontal: 16, 200 paddingVertical: 16, 201 }, 202 animatedFadeStyle, 203 ]} 204 > 205 <TopControlBar 206 offline={offline} 207 isActivelyLive={isActivelyLive} 208 ingest={ingest} 209 isChatOpen={isChatOpen} 210 onToggleChat={toggleChat} 211 safeAreaInsets={safeAreaInsets} 212 /> 213 </Animated.View> 214 215 {isActivelyLive && isControlsVisible && ( 216 <View 217 style={[ 218 layout.position.absolute, 219 { 220 transform: [{ translateX: -100 }, { translateY: -25 }], 221 }, 222 ]} 223 > 224 <Animated.View 225 style={[ 226 { 227 padding: 12, 228 backgroundColor: "rgba(0, 0, 0, 0.5)", 229 }, 230 r[3], 231 animatedFadeStyle, 232 ]} 233 > 234 <PlayerUI.MetricsPanel showMetrics={isActivelyLive} /> 235 </Animated.View> 236 </View> 237 )} 238 239 <Animated.View 240 style={[ 241 layout.position.absolute, 242 position.bottom[0], 243 w.percent[100], 244 { 245 backgroundColor: "rgba(0, 0, 0, 0.6)", 246 paddingHorizontal: 16, 247 paddingVertical: 2, 248 paddingBottom: 2, 249 }, 250 animatedFadeStyle, 251 ]} 252 > 253 <BottomControlBar 254 ingest={ingest} 255 pipSupported={pipSupported} 256 pipActive={pipActive} 257 onHandlePip={handlePip} 258 dropdownPortalContainer={dropdownPortalContainer} 259 /> 260 </Animated.View> 261 262 {isSelfAndNotLive && ( 263 <PlayerUI.InputPanel 264 title={title} 265 setTitle={setTitle} 266 ingestStarting={ingestStarting} 267 toggleGoLive={toggleGoLive} 268 /> 269 )} 270 271 <PlayerUI.CountdownOverlay 272 visible={showCountdown} 273 width={width} 274 height={height} 275 onDone={() => { 276 setShowCountdown(false); 277 }} 278 /> 279 280 <Toast 281 open={recordSubmitted} 282 onOpenChange={setRecordSubmitted} 283 title="You're live!" 284 description="We're notifying your followers that you just went live." 285 duration={5} 286 /> 287 </View> 288 {showMetrics && ( 289 <View 290 style={[ 291 layout.position.absolute, 292 position.top[20], 293 position.left[4], 294 px[4], 295 py[2], 296 { 297 backgroundColor: "rgba(0, 0, 0, 0.7)", 298 borderRadius: 8, 299 borderWidth: 1, 300 borderColor: "#374151", 301 }, 302 ]} 303 > 304 <PlayerUI.MetricsPanel showMetrics={showMetrics} /> 305 </View> 306 )} 307 </> 308 </GestureDetector> 309 ); 310}