Live video on the AT Protocol
at eli/nonfatal-errors 515 lines 16 kB view raw
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}