Live video on the AT Protocol
1import {
2 BottomTabIcon,
3 createBottomTabNavigator,
4} from "@react-navigation/bottom-tabs";
5import { useLinkTo, useNavigation } from "@react-navigation/native";
6import { createNativeStackNavigator } from "@react-navigation/native-stack";
7import {
8 Text,
9 useAccentColor,
10 usePrimaryColor,
11 useSiteTitle,
12 useTheme,
13 useToast,
14 zero,
15} from "@streamplace/components";
16import { Settings } from "components";
17import Login from "components/login/login";
18import LoginModal from "components/login/login-modal";
19import PdsHostSelectorModal from "components/login/pds-host-selector-modal";
20import { AboutCategorySettings } from "components/settings/about-category-settings";
21import { AccountCategorySettings } from "components/settings/account-category-settings";
22import { AdvancedCategorySettings } from "components/settings/advanced-category-settings";
23import { DanmuCategorySettings } from "components/settings/danmu-category-settings";
24import KeyManager from "components/settings/key-manager";
25import { LanguagesCategorySettings } from "components/settings/languages-category-settings";
26import MultistreamManager from "components/settings/multistream-manager";
27import { PrivacyCategorySettings } from "components/settings/privacy-category-settings";
28import RecommendationsManager from "components/settings/recommendations-manager";
29import { StreamingCategorySettings } from "components/settings/streaming-category-settings";
30import WebhookManager from "components/settings/webhook-manager";
31import { SidebarOverlay } from "components/sidebar/sidebar-overlay";
32import { useBlueskyNotifications } from "hooks/useBlueskyNotifications";
33import { useLiveUser } from "hooks/useLiveUser";
34import usePlatform from "hooks/usePlatform";
35import { useSidebarControl } from "hooks/useSidebarControl";
36import { useEffect, useRef, useState } from "react";
37import { Platform, StatusBar, View } from "react-native";
38import Animated, { useAnimatedStyle } from "react-native-reanimated";
39import { SFSymbols7_0 } from "sf-symbols-typescript";
40import "src/navigation-types";
41import AboutScreen from "src/screens/about";
42import AppReturnScreen from "src/screens/app-return";
43import PopoutChat from "src/screens/chat-popout";
44import DanmuOBSScreen from "src/screens/danmu-obs";
45import DownloadScreen from "src/screens/download";
46import EmbedScreen from "src/screens/embed";
47import HomeScreen from "src/screens/home";
48import InfoWidgetEmbed from "src/screens/info-widget-embed";
49import LaunchGoLive from "src/screens/launch-go-live";
50import LiveDashboard from "src/screens/live-dashboard";
51import MobileGoLive from "src/screens/mobile-go-live";
52import MobileStream from "src/screens/mobile-stream";
53import MultiScreen from "src/screens/multi";
54import SupportScreen from "src/screens/support";
55import { useStore } from "store";
56import {
57 useHydrated,
58 useNotificationDestination,
59 useNotificationToken,
60} from "store/hooks";
61import { AvatarButton, LGAvatarButton, NavigationButton } from "./router";
62
63const Tab = createBottomTabNavigator();
64const RootStack = createNativeStackNavigator();
65const HomeStack = createNativeStackNavigator();
66const SettingsStack = createNativeStackNavigator();
67
68// Home navigator (contains home + all general navigation screens)
69function HomeNavigator() {
70 const title = useSiteTitle() || "Streamplace Station";
71 return (
72 <HomeStack.Navigator
73 screenOptions={{
74 headerShown: true,
75 headerTransparent: Platform.OS === "ios",
76 headerBackButtonDisplayMode: "minimal",
77 }}
78 >
79 <HomeStack.Screen
80 name="HomeMain"
81 component={HomeScreen}
82 options={{
83 title: "Streamplace",
84 headerTitle:
85 Platform.OS === "ios"
86 ? (props) => (
87 <View style={{ flex: 1, alignItems: "flex-start" }}>
88 <Text size="3xl" style={[zero.ml[4]]}>
89 {title}
90 </Text>
91 </View>
92 )
93 : undefined,
94 headerLeft:
95 Platform.OS !== "ios"
96 ? ({ canGoBack }) => <NavigationButton canGoBack={canGoBack} />
97 : undefined,
98 headerRight: () => <AvatarButton />,
99 ...(Platform.OS === "ios" && {
100 unstable_headerRightItems: () => [
101 {
102 type: "custom",
103 hidesSharedBackground: true,
104 element: <LGAvatarButton />,
105 },
106 ],
107 }),
108 }}
109 />
110 <HomeStack.Screen
111 name="About"
112 component={AboutScreen}
113 options={{ title: "What's Streamplace?" }}
114 />
115 <HomeStack.Screen
116 name="Download"
117 component={DownloadScreen}
118 options={{ title: "Download" }}
119 />
120 <HomeStack.Screen
121 name="LiveDashboard"
122 component={LiveDashboard}
123 options={{ title: "Live Dashboard" }}
124 />
125 <HomeStack.Screen
126 name="Login"
127 component={Login}
128 options={{ title: "Login" }}
129 />
130 <HomeStack.Screen
131 name="Multi"
132 component={MultiScreen}
133 options={{ title: "Multi-stream" }}
134 />
135 <HomeStack.Screen
136 name="Support"
137 component={SupportScreen}
138 options={{ title: "Support" }}
139 />
140 </HomeStack.Navigator>
141 );
142}
143
144// Settings stack navigator
145function SettingsNavigator() {
146 const z = useTheme();
147 return (
148 <SettingsStack.Navigator
149 initialRouteName="MainSettings"
150 screenOptions={{
151 headerShown: true,
152 headerTransparent: Platform.OS === "ios",
153 headerBackButtonDisplayMode: "minimal",
154 headerTitleStyle: {
155 fontFamily: z.theme.typography.universal["2xl"].fontFamily,
156 },
157 }}
158 >
159 <SettingsStack.Screen
160 name="MainSettings"
161 component={Settings}
162 options={{ title: "Settings" }}
163 />
164 <SettingsStack.Screen
165 name="AboutCategory"
166 component={AboutCategorySettings}
167 options={{ title: "About" }}
168 />
169 <SettingsStack.Screen
170 name="AccountCategory"
171 component={AccountCategorySettings}
172 options={{ title: "Account" }}
173 />
174 <SettingsStack.Screen
175 name="StreamingCategory"
176 component={StreamingCategorySettings}
177 options={{ title: "Streaming" }}
178 />
179 <SettingsStack.Screen
180 name="WebhooksSettings"
181 component={WebhookManager}
182 options={{ title: "Webhooks" }}
183 />
184 <SettingsStack.Screen
185 name="RecommendationsSettings"
186 component={RecommendationsManager}
187 options={{ title: "Recommendations" }}
188 />
189 <SettingsStack.Screen
190 name="PrivacyCategory"
191 component={PrivacyCategorySettings}
192 options={{ title: "Privacy & Security" }}
193 />
194 <SettingsStack.Screen
195 name="DanmuCategory"
196 component={DanmuCategorySettings}
197 options={{ title: "Danmu" }}
198 />
199 <SettingsStack.Screen
200 name="AdvancedCategory"
201 component={AdvancedCategorySettings}
202 options={{ title: "Advanced" }}
203 />
204 <SettingsStack.Screen
205 name="MultistreamCategory"
206 component={MultistreamManager}
207 options={{ title: "Multistream" }}
208 />
209 <SettingsStack.Screen
210 name="LanguagesCategory"
211 component={LanguagesCategorySettings}
212 options={{ title: "Languages" }}
213 />
214 <SettingsStack.Screen
215 name="KeyManagement"
216 component={KeyManager}
217 options={{ title: "Key Manager" }}
218 />
219 </SettingsStack.Navigator>
220 );
221}
222
223const IOS_ICONS: Record<string, SFSymbols7_0> = {
224 Home: "house.fill",
225 GoLive: "video.fill",
226 Settings: "gearshape.fill",
227};
228const ANDROID_ICONS = {
229 Home: "home",
230 GoLive: "videocam",
231 Settings: "settings",
232};
233
234const getIcon = (
235 name: keyof typeof IOS_ICONS | keyof typeof ANDROID_ICONS,
236): BottomTabIcon => {
237 if (Platform.OS === "ios") {
238 return {
239 type: "sfSymbol",
240 name: IOS_ICONS[name],
241 };
242 }
243 return {
244 type: "materialSymbol",
245 name: ANDROID_ICONS[name],
246 };
247};
248
249// Tab navigator (main app sections, navigation on web is handled in sidebar)
250function TabNavigator() {
251 const { isNative, isBrowser } = usePlatform();
252 const accentColor = useAccentColor();
253 const primaryColor = usePrimaryColor();
254 const z = useTheme();
255
256 return (
257 <Tab.Navigator
258 screenOptions={{
259 lazy: true,
260 headerShown: false,
261 // Hide tab bar on web
262 tabBarStyle: isNative ? undefined : { display: "none" },
263 // doesn't seem to work on iOS?
264 // tabBarInactiveTintColor: primaryColor || accentColor || "#f0f",
265 // tabBarActiveTintColor: accentColor || primaryColor || "#06f",
266 headerTitleStyle: {
267 fontFamily: z.theme.typography.universal["2xl"].fontFamily,
268 },
269 }}
270 >
271 <Tab.Screen
272 name="HomeTab"
273 component={HomeNavigator}
274 options={{
275 title: "Home",
276 ...(isNative && {
277 tabBarIcon: getIcon("Home"),
278 }),
279 }}
280 />
281 <Tab.Screen
282 name="GoLiveTab"
283 component={LaunchGoLive}
284 options={{
285 title: "Go Live",
286 ...(isNative && {
287 tabBarIcon: getIcon("GoLive"),
288 }),
289 headerShown: true,
290 headerTransparent: true,
291 }}
292 />
293 <Tab.Screen
294 name="SettingsTab"
295 component={SettingsNavigator}
296 options={{
297 title: "Settings",
298 ...(isNative && {
299 tabBarIcon: getIcon("Settings"),
300 }),
301 headerShown: false,
302 }}
303 />
304 </Tab.Navigator>
305 );
306}
307
308export default function Shell() {
309 const { isNative } = usePlatform();
310 const sidebar = useSidebarControl();
311 const navigation = useNavigation();
312 const hydrate = useStore((state) => state.hydrate);
313 const initPushNotifications = useStore(
314 (state) => state.initPushNotifications,
315 );
316 const registerNotificationToken = useStore(
317 (state) => state.registerNotificationToken,
318 );
319 const clearNotification = useStore((state) => state.clearNotification);
320 const pollMySegments = useStore((state) => state.pollMySegments);
321 const showLoginModal = useStore((state) => state.showLoginModal);
322 const closeLoginModal = useStore((state) => state.closeLoginModal);
323 const showPdsModal = useStore((state) => state.showPdsModal);
324 const openPdsModal = useStore((state) => state.openPdsModal);
325 const closePdsModal = useStore((state) => state.closePdsModal);
326 const loginAction = useStore((state) => state.login);
327 const openLoginLink = useStore((state) => state.openLoginLink);
328 const livePopupShown = useRef(false);
329 const z = useTheme();
330
331 const toast = useToast();
332
333 // Top-level hydration and initialization
334 useEffect(() => {
335 hydrate();
336 initPushNotifications();
337 }, []);
338
339 const notificationToken = useNotificationToken();
340 const hydrated = useHydrated();
341
342 useEffect(() => {
343 if (notificationToken) {
344 registerNotificationToken();
345 }
346 }, [notificationToken]);
347
348 // Handle incoming push notification routing
349 const notificationDestination = useNotificationDestination();
350 const linkTo = useLinkTo();
351
352 useEffect(() => {
353 if (notificationDestination) {
354 linkTo(notificationDestination);
355 clearNotification();
356 }
357 }, [notificationDestination]);
358
359 // Poll for live streamers
360 useEffect(() => {
361 let handle: NodeJS.Timeout;
362 handle = setInterval(() => {
363 pollMySegments();
364 }, 2500);
365 pollMySegments();
366 return () => clearInterval(handle);
367 }, []);
368
369 const userIsLive = useLiveUser();
370 useBlueskyNotifications();
371
372 // Track current route
373 const [currentRouteName, setCurrentRouteName] = useState<
374 string | undefined
375 >();
376
377 useEffect(() => {
378 const unsubscribe = navigation.addListener("state", () => {
379 const state = navigation.getState();
380 if (state?.routes) {
381 const currentRoute = state.routes[state.index];
382 console.log("setCurrentRouteName", currentRoute?.name);
383 setCurrentRouteName(currentRoute?.name);
384 }
385 });
386 return unsubscribe;
387 }, [navigation]);
388
389 const noLivePopupRoutes =
390 currentRouteName === "LiveDashboard" ||
391 currentRouteName === "GoLiveTab" ||
392 currentRouteName === "MobileGoLive";
393
394 // Show "You are live!" toast once per live session
395 useEffect(() => {
396 if (!userIsLive) {
397 livePopupShown.current = false;
398 return;
399 }
400 if (!noLivePopupRoutes && !livePopupShown.current) {
401 livePopupShown.current = true;
402 toast.show("You are live!", "Do you want to go to your Live Dashboard?", {
403 actionLabel: "Go",
404 onAction: () => {
405 navigation.navigate("MainTabs" as any, {
406 screen: "HomeTab",
407 params: { screen: "LiveDashboard" },
408 });
409 },
410 variant: "error",
411 duration: 8,
412 });
413 }
414 }, [userIsLive, noLivePopupRoutes]);
415
416 // Animate content margin when sidebar is active (web only)
417 const animatedContentStyle = useAnimatedStyle(() => {
418 if (isNative || !sidebar.isActive) {
419 return { marginLeft: 0 };
420 }
421 return {
422 marginLeft: sidebar.animatedWidth.value,
423 };
424 });
425
426 if (!hydrated) {
427 return <View />;
428 }
429
430 return (
431 <>
432 <StatusBar barStyle="light-content" />
433 {!isNative && <SidebarOverlay />}
434 <Animated.View style={[{ flex: 1 }, animatedContentStyle]}>
435 <RootStack.Navigator
436 screenOptions={{
437 headerShown: !isNative,
438 headerLeft: ({ canGoBack }) => (
439 <NavigationButton canGoBack={canGoBack} />
440 ),
441 headerRight: () => <LGAvatarButton />,
442 ...(isNative && {
443 headerTransparent: true,
444 }),
445 headerTitleStyle: {
446 fontFamily: z.theme.typography.universal.base.fontFamily,
447 },
448 }}
449 >
450 {/* Main tabs (initial screen for all platforms) */}
451 <RootStack.Screen
452 name="MainTabs"
453 component={TabNavigator}
454 options={{ headerShown: false }}
455 />
456
457 {/* Full-screen screens that should NOT have tab bar accessible on mobile */}
458 <RootStack.Screen
459 name="Stream"
460 component={MobileStream}
461 options={{
462 headerShown: Platform.OS === "web",
463 headerTitle: "",
464 }}
465 />
466 <RootStack.Screen
467 name="MobileGoLive"
468 component={MobileGoLive}
469 options={{ headerShown: false }}
470 />
471
472 {/* Utility/embed screens */}
473 <RootStack.Screen
474 name="AppReturn"
475 component={AppReturnScreen}
476 options={{ title: "Returning to app..." }}
477 />
478 <RootStack.Screen
479 name="PopoutChat"
480 component={PopoutChat}
481 options={{ headerShown: false }}
482 />
483 <RootStack.Screen
484 name="Embed"
485 component={EmbedScreen}
486 options={{ headerShown: false }}
487 />
488 <RootStack.Screen
489 name="InfoWidgetEmbed"
490 component={InfoWidgetEmbed}
491 options={{ headerShown: false }}
492 />
493 <RootStack.Screen
494 name="DanmuOBS"
495 component={DanmuOBSScreen}
496 options={{ headerShown: false }}
497 />
498 </RootStack.Navigator>
499 </Animated.View>
500 <LoginModal
501 visible={showLoginModal}
502 onClose={closeLoginModal}
503 onOpenPdsModal={openPdsModal}
504 />
505 <PdsHostSelectorModal
506 open={showPdsModal}
507 onOpenChange={closePdsModal}
508 onSubmit={(pdsHost) => {
509 closePdsModal();
510 loginAction(pdsHost, openLoginLink);
511 }}
512 />
513 </>
514 );
515}