Live video on the AT Protocol
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}