Live video on the AT Protocol
1import { useNavigation } from "@react-navigation/native";
2import {
3 PlayerUI,
4 Text,
5 Toast,
6 useAvatars,
7 useCameraToggle,
8 useLivestreamInfo,
9 usePlayerDimensions,
10 usePlayerStore,
11 useSegmentDimensions,
12 View,
13 zero,
14} from "@streamplace/components";
15import { ChevronLeft, SwitchCamera, VolumeX } from "lucide-react-native";
16import { useEffect, useRef, useState } from "react";
17import { Image, Pressable, TouchableWithoutFeedback } from "react-native";
18import Animated, {
19 useAnimatedStyle,
20 useSharedValue,
21 withTiming,
22} from "react-native-reanimated";
23import { MobileChatPanel } from "./chat";
24import { useResponsiveLayout } from "./useResponsiveLayout";
25
26const { borders, colors, gap, h, layout, position, w, bottom, px, py, r } =
27 zero;
28
29export function MobileUi() {
30 const navigation = useNavigation();
31 const {
32 ingest,
33 profile,
34 title,
35 setTitle,
36 showCountdown,
37 setShowCountdown,
38 recordSubmitted,
39 setRecordSubmitted,
40 ingestStarting,
41 setIngestStarting,
42 toggleGoLive,
43 } = useLivestreamInfo();
44 const { width, height } = usePlayerDimensions();
45 const { isPlayerRatioGreater } = useSegmentDimensions();
46 const { doSetIngestCamera } = useCameraToggle();
47 const avatars = useAvatars(profile?.did ? [profile?.did] : []);
48
49 const muteWasForced = usePlayerStore((state) => state.muteWasForced);
50 const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced);
51 const setMuted = usePlayerStore((state) => state.setMuted);
52
53 const { shouldShowFloatingMetrics, safeAreaInsets } = useResponsiveLayout();
54 const [showLoading, setShowLoading] = useState(false);
55
56 useEffect(() => {
57 return () => {
58 if (ingestStarting) {
59 setIngestStarting(false);
60 }
61 };
62 }, [ingestStarting, setIngestStarting]);
63
64 useEffect(() => {
65 if (recordSubmitted) setShowLoading(false);
66 }, [recordSubmitted]);
67
68 const isSelfAndNotLive = ingest === "new";
69 const isLive = ingest !== null && ingest !== "new";
70
71 const FADE_OUT_DELAY = 4000;
72 const fadeOpacity = useSharedValue(1);
73 const fadeTimeout = useRef<NodeJS.Timeout | null>(null);
74
75 const resetFadeTimer = () => {
76 fadeOpacity.value = withTiming(1, { duration: 200 });
77 if (fadeTimeout.current) clearTimeout(fadeTimeout.current);
78 fadeTimeout.current = setTimeout(() => {
79 fadeOpacity.value = withTiming(0, { duration: 400 });
80 }, FADE_OUT_DELAY);
81 };
82
83 useEffect(() => {
84 resetFadeTimer();
85 return () => {
86 if (fadeTimeout.current) clearTimeout(fadeTimeout.current);
87 };
88 }, []);
89
90 const animatedFadeStyle = useAnimatedStyle(() => ({
91 opacity: shouldShowFloatingMetrics ? 1 : fadeOpacity.value,
92 }));
93
94 return (
95 <>
96 <TouchableWithoutFeedback onPress={resetFadeTimer}>
97 <Animated.View
98 style={[
99 layout.position.absolute,
100 h.percent[100],
101 w.percent[100],
102 animatedFadeStyle,
103 ]}
104 >
105 {/* Main UI Overlay */}
106 <View
107 style={[layout.position.absolute, h.percent[100], w.percent[100]]}
108 >
109 {/* Top Left - Back Button and Profile */}
110 <View
111 style={[
112 {
113 padding: 3,
114 paddingRight: 8,
115 backgroundColor: "rgba(90,90,90, 0.25)",
116 borderRadius: 12,
117 },
118 r[2],
119 layout.position.absolute,
120 position.left[2],
121 { top: 12 },
122 ]}
123 >
124 <View style={[layout.flex.row, layout.flex.center, gap.all[2]]}>
125 <Pressable
126 onPress={() => {
127 navigation.canGoBack()
128 ? navigation.goBack()
129 : navigation.navigate("Home", { screen: "StreamList" });
130 }}
131 >
132 <ChevronLeft color="white" />
133 </Pressable>
134 {shouldShowFloatingMetrics && (
135 <>
136 <Image
137 source={
138 profile?.did
139 ? { url: avatars[profile?.did]?.avatar }
140 : require("assets/images/goose.png")
141 }
142 width={32}
143 height={32}
144 style={[
145 {
146 width: 36,
147 height: 36,
148 backgroundColor: "green",
149 },
150 { borderRadius: 999 },
151 borders.width.thin,
152 borders.color.gray[700],
153 ]}
154 />
155 <Text>{profile?.handle}</Text>
156 </>
157 )}
158 </View>
159 </View>
160
161 {shouldShowFloatingMetrics && (
162 <View
163 style={[
164 {
165 padding: 9,
166 backgroundColor: "rgba(90,90,90, 0.3)",
167 borderRadius: 12,
168 },
169 r[2],
170 layout.position.absolute,
171 position.right[14],
172 { top: 12 },
173 gap.all[4],
174 ]}
175 >
176 <PlayerUI.Viewers />
177 </View>
178 )}
179
180 <View
181 style={[
182 {
183 padding: 13,
184 backgroundColor: "rgba(0, 0, 0, 0.5)",
185 borderRadius: 12,
186 },
187 r[2],
188 layout.position.absolute,
189 position.right[1],
190 { top: 8 },
191 gap.all[4],
192 ]}
193 >
194 {ingest === null ? (
195 <PlayerUI.ContextMenu />
196 ) : (
197 <Pressable onPress={doSetIngestCamera}>
198 <SwitchCamera size={32} color={colors.gray[200]} />
199 </Pressable>
200 )}
201 </View>
202
203 {shouldShowFloatingMetrics && isLive && (
204 <View
205 style={[
206 layout.position.absolute,
207 { top: safeAreaInsets.top + 112 },
208 position.left[0],
209 position.right[0],
210 layout.flex.column,
211 layout.flex.center,
212 ]}
213 >
214 <PlayerUI.MetricsPanel
215 showMetrics={isLive || isSelfAndNotLive}
216 />
217 </View>
218 )}
219 </View>
220
221 {isSelfAndNotLive && (
222 <PlayerUI.InputPanel
223 title={title}
224 setTitle={setTitle}
225 ingestStarting={ingestStarting}
226 toggleGoLive={toggleGoLive}
227 />
228 )}
229
230 <PlayerUI.CountdownOverlay
231 visible={showCountdown}
232 width={width}
233 height={height - 150}
234 onDone={() => {
235 if (!recordSubmitted && title != "") {
236 setShowLoading(true);
237 }
238 setShowCountdown(false);
239 }}
240 />
241 <PlayerUI.LoadingOverlay
242 visible={showLoading}
243 width={width}
244 height={height - 150}
245 subtitle="We're setting up your stream."
246 />
247
248 <Toast
249 open={recordSubmitted}
250 onOpenChange={setRecordSubmitted}
251 title="You're live!"
252 description="We're notifying your followers that you just went live."
253 duration={5}
254 />
255 </Animated.View>
256 </TouchableWithoutFeedback>
257
258 {!isSelfAndNotLive && (
259 <MobileChatPanel isPlayerRatioGreater={isPlayerRatioGreater} />
260 )}
261 {muteWasForced && (
262 <View
263 style={[
264 layout.position.absolute,
265 position.top[14],
266 position.right[2],
267 layout.flex.column,
268 layout.flex.center,
269 ]}
270 >
271 <Pressable
272 onPress={() => {
273 if (muteWasForced) {
274 setMuted(false);
275 setMuteWasForced(false);
276 }
277 }}
278 style={[
279 {
280 flexDirection: "row",
281 alignItems: "center",
282 gap: 8,
283 },
284 ]}
285 >
286 <Text color="muted" size="sm">
287 Tap to unmute
288 </Text>
289 <View
290 style={[
291 {
292 padding: 4,
293 backgroundColor: "rgba(50, 30, 30, 0.4)",
294 borderRadius: 999,
295 borderWidth: 2,
296 borderColor: "rgba(255, 120, 120, 0.2)",
297 },
298 ]}
299 >
300 <VolumeX size="24" color="rgba(255,120,120,0.8)" />
301 </View>
302 </Pressable>
303 </View>
304 )}
305 <PlayerUI.AutoplayButton />
306 </>
307 );
308}