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