import { BottomTabIcon, createBottomTabNavigator, } from "@react-navigation/bottom-tabs"; import { useLinkTo, useNavigation } from "@react-navigation/native"; import { createNativeStackNavigator, NativeStackHeaderBackProps, NativeStackNavigationOptions, } from "@react-navigation/native-stack"; import { Text, useAccentColor, useBrandingSettled, useDID, usePrimaryColor, useStreamplaceStore, useTheme, withAlpha, zero, } from "@streamplace/components"; import { colors, spacing } from "@streamplace/components/src/lib/theme/tokens"; import { Settings } from "components"; import { SiteTitleLockup, useNodeTitle } from "components/brand/logo"; import { LogoBrandMenu } from "components/brand/logo-brand-menu"; import Login from "components/login/login"; import LoginModal from "components/login/login-modal"; import PdsHostSelectorModal from "components/login/pds-host-selector-modal"; import { MobileAppBanner } from "components/mobile-app-banner"; import { AboutCategorySettings } from "components/settings/about-category-settings"; import { AccountCategorySettings } from "components/settings/account-category-settings"; import { AdvancedCategorySettings } from "components/settings/advanced-category-settings"; import { BackupSettings } from "components/settings/backup-settings"; import { BadgeIssuerPanel } from "components/settings/badge-issuer-panel"; import { BadgeSelectionManager } from "components/settings/badge-selection-manager"; import { BrandingAdmin } from "components/settings/branding-admin"; import { DanmuCategorySettings } from "components/settings/danmu-category-settings"; import KeyManager from "components/settings/key-manager"; import { LanguagesCategorySettings } from "components/settings/languages-category-settings"; import MultistreamManager from "components/settings/multistream-manager"; import { NotificationsCategorySettings } from "components/settings/notifications-category-settings"; import { PrivacyCategorySettings } from "components/settings/privacy-category-settings"; import RecommendationsManager from "components/settings/recommendations-manager"; import { StreamingCategorySettings } from "components/settings/streaming-category-settings"; import WebhookManager from "components/settings/webhook-manager"; import { SidebarOverlay, SidebarToggle, } from "components/sidebar/sidebar-overlay"; import UploadProgressIndicator from "components/upload/upload-progress-indicator"; import { useBlueskyNotifications } from "hooks/useBlueskyNotifications"; import usePlatform from "hooks/usePlatform"; import { useIsLargeScreen, useSidebarControl } from "hooks/useSidebarControl"; import { Clapperboard, Cog, Home, Video } from "lucide-react-native"; import { useEffect, useState } from "react"; import { Platform, Pressable, StatusBar, View } from "react-native"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { SFSymbols7_0 } from "sf-symbols-typescript"; import "src/navigation-types"; import AboutScreen from "src/screens/about"; import AppReturnScreen from "src/screens/app-return"; import PopoutChat from "src/screens/chat-popout"; import DanmuOBSScreen from "src/screens/danmu-obs"; import DownloadScreen from "src/screens/download"; import EmbedScreen from "src/screens/embed"; import HomeScreen from "src/screens/home"; import InfoWidgetEmbed from "src/screens/info-widget-embed"; import LaunchGoLive from "src/screens/launch-go-live"; import LiveDashboard from "src/screens/live-dashboard"; import MobileGoLive from "src/screens/mobile-go-live"; import MobileStream from "src/screens/mobile-stream"; import MultiScreen from "src/screens/multi"; import PopoutInfoWidget from "src/screens/popout-info-widget"; import PopoutLivestream from "src/screens/popout-livestream"; import PopoutMultistream from "src/screens/popout-multistream"; import PopoutStreamMonitor from "src/screens/popout-stream-monitor"; import SupportScreen from "src/screens/support"; import UploadScreen, { UploadDraftsScreen, UploadLivestreamsScreen, UploadVideoScreen, UploadVideosScreen, } from "src/screens/upload"; import VideoScreen from "src/screens/video"; import VideoListScreen from "src/screens/video-list"; import VodScreen from "src/screens/vod"; import VodEmbedScreen from "src/screens/vod-embed"; import { useStore } from "store"; import { useHydrated, useNotificationDestination, useNotificationToken, } from "store/hooks"; import { AvatarButton, LGAvatarButton, NavigationButton, UploadButton, } from "./router"; const AnimatedPressable = Animated.createAnimatedComponent(Pressable); const Tab = createBottomTabNavigator(); const RootStack = createNativeStackNavigator(); const HomeStack = createNativeStackNavigator(); const VideosStack = createNativeStackNavigator(); const SettingsStack = createNativeStackNavigator(); function useBaseScreenOptions() { const z = useTheme(); return { headerShown: true, headerTransparent: Platform.OS === "ios", headerBackButtonDisplayMode: "minimal" as const, // Slim, quiet chrome: 15px medium title on surface0 with a hairline headerTitleStyle: { fontFamily: z.theme.typography.universal.xl.fontFamily, fontSize: 15, fontWeight: "500" as const, color: z.theme.colors.text1, }, headerStyle: { backgroundColor: Platform.OS === "web" ? z.theme.colors.surface0 : undefined, borderBottomColor: z.theme.colors.borderSubtle, borderBottomWidth: 1, // Slimmer bar, aligned with the 56px sidebar brand row. height: 56, }, }; } // Home navigator (contains home + all general navigation screens) function HomeNavigator() { const title = useNodeTitle(); const baseScreenOptions = useBaseScreenOptions(); const isNative = Platform.OS !== "web"; const z = useTheme(); const did = useDID(); const headerScreenOptions: NativeStackNavigationOptions = { headerShown: !isNative, headerLeft: isNative ? undefined : ({ canGoBack }: NativeStackHeaderBackProps) => ( ), headerRight: () => ( ), ...(isNative && { headerTransparent: true, }), ...(Platform.OS === "ios" && { unstable_headerRightItems: () => [ { type: "custom", hidesSharedBackground: true, element: ( ), }, ], }), headerTitleStyle: { fontFamily: z.theme.typography.universal.xl.fontFamily, fontSize: 15, fontWeight: "500" as const, color: z.theme.colors.text1, }, }; return ( ( {title} ) : "Home", headerLeft: Platform.OS !== "ios" ? ({ canGoBack }) => : undefined, headerRight: () => ( ), ...(Platform.OS === "ios" && { unstable_headerRightItems: () => [ { type: "custom", hidesSharedBackground: true, element: ( ), }, ], }), }} /> {!isNative && ( )} {!isNative && ( )} {!isNative && ( )} {!isNative && ( )} {!isNative && ( )} ); } // Videos stack navigator (global + per-user VOD listings). Unlike the pushed // Home screens, these are tab roots, so they always carry their own header. function VideosNavigator() { const baseScreenOptions = useBaseScreenOptions(); const isNative = Platform.OS !== "web"; return ( ( ), headerRight: () => ( ), ...(Platform.OS === "ios" && { unstable_headerRightItems: () => [ { type: "custom", hidesSharedBackground: true, element: ( ), }, ], }), }} > ); } // Settings stack navigator function SettingsNavigator() { const baseScreenOptions = useBaseScreenOptions(); const headerScreenOptions: NativeStackNavigationOptions = { ...baseScreenOptions, headerTransparent: Platform.OS === "ios", headerBackButtonDisplayMode: "minimal" as const, headerShown: true, // Same Create + avatar cluster as the other navigators, so the header // controls don't vanish on Settings screens. headerRight: () => ( ), ...(Platform.OS === "ios" && { unstable_headerRightItems: () => [ { type: "custom", hidesSharedBackground: true, element: ( ), }, ], }), }; return ( ); } const IOS_ICONS: Record = { Home: "house.fill", Videos: "play.rectangle.fill", Settings: "gearshape.fill", }; const ANDROID_ICONS = { Home: "home", Videos: "video_library", Settings: "settings", }; const getIcon = ( name: keyof typeof IOS_ICONS | keyof typeof ANDROID_ICONS, ): BottomTabIcon => { if (Platform.OS === "ios") { return { type: "sfSymbol", name: IOS_ICONS[name], }; } else { return { type: "materialSymbol", name: ANDROID_ICONS[name], }; } }; // Tab navigator (main app sections, navigation on web is handled in sidebar) function TabNavigator() { const { isNative, isBrowser } = usePlatform(); const accentColor = useAccentColor(); const primaryColor = usePrimaryColor(); const isLargeScreen = useIsLargeScreen(); const z = useTheme(); return ( ( ), }), }} /> ( ), }), }} /> {!isNative && ( ( ); } // Walk the (possibly nested) navigation state to the deepest active route, so // checks against leaf screen names like "LiveDashboard" resolve correctly — // getState().routes[index].name alone only yields the top-level route // (e.g. "MainTabs"), so nested screens never match. function getActiveLeafRouteName(state: any): string | undefined { if (!state?.routes) return undefined; const route = state.routes[state.index ?? 0]; if (route?.state) return getActiveLeafRouteName(route.state) ?? route.name; return route?.name; } export default function Shell() { const { isNative } = usePlatform(); const sidebar = useSidebarControl(); const navigation = useNavigation(); const hydrate = useStore((state) => state.hydrate); const initPushNotifications = useStore( (state) => state.initPushNotifications, ); const registerNotificationToken = useStore( (state) => state.registerNotificationToken, ); const clearNotification = useStore((state) => state.clearNotification); const pollMySegments = useStore((state) => state.pollMySegments); const showLoginModal = useStore((state) => state.showLoginModal); const closeLoginModal = useStore((state) => state.closeLoginModal); const showPdsModal = useStore((state) => state.showPdsModal); const openPdsModal = useStore((state) => state.openPdsModal); const closePdsModal = useStore((state) => state.closePdsModal); const loginAction = useStore((state) => state.login); const openLoginLink = useStore((state) => state.openLoginLink); const z = useTheme(); const baseScreenOptions = useBaseScreenOptions(); // Top-level hydration and initialization useEffect(() => { hydrate(); initPushNotifications(); }, []); const notificationToken = useNotificationToken(); const did = useStore((state) => state.oauthSession?.did); const hydrated = useHydrated(); // Two more things the first frame waits for (bounded by the same timeout): // the OAuth session restore, so a signed-in viewer never sees the wall // that the anonymous status answer would paint; and branding, so the // default mark and title never flash before the node's own. const sessionRestoring = useStreamplaceStore( (s) => s.oauthSession === undefined, ); const brandingSettled = useBrandingSettled(); const [bootTimedOut, setBootTimedOut] = useState(false); useEffect(() => { const handle = setTimeout(() => setBootTimedOut(true), 6000); return () => clearTimeout(handle); }, []); // Re-register when the token changes OR once the logged-in DID resolves, so a // token acquired before the OAuth session finishes restoring still gets its // repoDID association registered (otherwise the user is excluded from // follower livestream notifications). useEffect(() => { if (notificationToken) { registerNotificationToken(); } }, [notificationToken, did]); // Handle incoming push notification routing const notificationDestination = useNotificationDestination(); const linkTo = useLinkTo(); useEffect(() => { if (notificationDestination) { linkTo(notificationDestination); clearNotification(); } }, [notificationDestination]); // Poll for live streamers useEffect(() => { let handle: NodeJS.Timeout; handle = setInterval(() => { pollMySegments(); }, 2500); pollMySegments(); return () => clearInterval(handle); }, []); useBlueskyNotifications(); // Track current route const [currentRouteName, setCurrentRouteName] = useState( () => { try { return getActiveLeafRouteName(navigation.getState()); } catch { return undefined; } }, ); useEffect(() => { const update = () => { const name = getActiveLeafRouteName(navigation.getState()); if (name) setCurrentRouteName(name); }; update(); // seed on mount so direct loads (e.g. a stream URL) resolve now const unsubscribe = navigation.addListener("state", update); return unsubscribe; }, [navigation]); // Detail views (watching a stream or video) take the sidebar out of the flow // so it opens as an overlay drawer over dimmed content instead of pushing. const setOverlay = useStore((state) => state.setOverlay); const closeDrawer = useStore((state) => state.closeDrawer); // A video page reports itself while mounted (the front door showing the // default video is one the route name doesn't reveal). const videoPage = useStore((state) => state.videoPage); const isDetailView = currentRouteName === "Stream" || currentRouteName === "Video" || currentRouteName === "Vod" || videoPage; // Video pages get a YouTube-style sticky translucent header the content // scrolls under; the livestream keeps its own solid header. const isVodDetail = currentRouteName === "Video" || currentRouteName === "Vod" || videoPage; useEffect(() => { setOverlay(isDetailView); }, [isDetailView, setOverlay]); // Animate content margin when sidebar is active (web only). In overlay mode // the content stays full width (margin 0) and the drawer floats over it. const animatedContentStyle = useAnimatedStyle(() => { if (isNative || !sidebar.isActive) { return { marginLeft: 0 }; } return { marginLeft: sidebar.animatedContentMargin.value, }; }); // Scrim that dims content behind the overlay drawer. const scrimStyle = useAnimatedStyle(() => ({ opacity: sidebar.animatedScrim.value, })); if (!hydrated) { return ; } if ((sessionRestoring || !brandingSettled) && !bootTimedOut) { return ; } return ( {!isNative && } {!isNative && } ( ), headerRight: () => ( ), ...(isNative && { headerTransparent: true, }), }} > {/* Main tabs (initial screen for all platforms) */} {/* Full-screen screens that should NOT have tab bar accessible on mobile */} {/* Utility/embed screens */} {/* Scrim behind the overlay drawer (detail views only) */} {!isNative && ( )} {/* Header cluster on detail views: hamburger + logo, sitting exactly where the drawer's brand row will appear so it stays put as the drawer opens. */} {!isNative && sidebar.isActive && sidebar.overlay && !sidebar.drawerOpen && ( on web href="/" style={{ flexShrink: 1, minWidth: 0 }} onPress={(e: any) => { e?.preventDefault?.(); navigation.navigate("MainTabs" as any, { screen: "HomeTab", params: { screen: "HomeMain" }, }); }} > )} { closePdsModal(); loginAction(pdsHost, openLoginLink); }} /> ); }