Live video on the AT Protocol
at next 594 lines 18 kB view raw
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}