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 { SiteTitleLockup, useNodeTitle } from "components/brand/logo";
import { LogoBrandMenu } from "components/brand/logo-brand-menu";
import HeaderSearch from "components/header-search";
import LoginModal from "components/login/login-modal";
import PdsHostSelectorModal from "components/login/pds-host-selector-modal";
import { MobileAppBanner } from "components/mobile-app-banner";
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, Search, 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 { lazyScreen } from "src/lazy-screen";
import "src/navigation-types";
import HomeScreen from "src/screens/home";
import { useStore } from "store";
import {
useHydrated,
useNotificationDestination,
useNotificationToken,
} from "store/hooks";
import {
AvatarButton,
LGAvatarButton,
NavigationButton,
UploadButton,
} from "./router";
// Every screen except the home feed is code-split, so its module (and
// dependencies) only load when the route is first visited.
const Login = lazyScreen(() => import("components/login/login"));
const Settings = lazyScreen(() =>
import("components/settings/settings").then((m) => ({ default: m.Settings })),
);
const AboutCategorySettings = lazyScreen(() =>
import("components/settings/about-category-settings").then((m) => ({
default: m.AboutCategorySettings,
})),
);
const AccountCategorySettings = lazyScreen(() =>
import("components/settings/account-category-settings").then((m) => ({
default: m.AccountCategorySettings,
})),
);
const AdvancedCategorySettings = lazyScreen(() =>
import("components/settings/advanced-category-settings").then((m) => ({
default: m.AdvancedCategorySettings,
})),
);
const BackupSettings = lazyScreen(() =>
import("components/settings/backup-settings").then((m) => ({
default: m.BackupSettings,
})),
);
const BadgeIssuerPanel = lazyScreen(() =>
import("components/settings/badge-issuer-panel").then((m) => ({
default: m.BadgeIssuerPanel,
})),
);
const BadgeSelectionManager = lazyScreen(() =>
import("components/settings/badge-selection-manager").then((m) => ({
default: m.BadgeSelectionManager,
})),
);
const BrandingAdmin = lazyScreen(() =>
import("components/settings/branding-admin").then((m) => ({
default: m.BrandingAdmin,
})),
);
const DanmuCategorySettings = lazyScreen(() =>
import("components/settings/danmu-category-settings").then((m) => ({
default: m.DanmuCategorySettings,
})),
);
const KeyManager = lazyScreen(() => import("components/settings/key-manager"));
const LanguagesCategorySettings = lazyScreen(() =>
import("components/settings/languages-category-settings").then((m) => ({
default: m.LanguagesCategorySettings,
})),
);
const MultistreamManager = lazyScreen(
() => import("components/settings/multistream-manager"),
);
const NotificationsCategorySettings = lazyScreen(() =>
import("components/settings/notifications-category-settings").then((m) => ({
default: m.NotificationsCategorySettings,
})),
);
const PrivacyCategorySettings = lazyScreen(() =>
import("components/settings/privacy-category-settings").then((m) => ({
default: m.PrivacyCategorySettings,
})),
);
const RecommendationsManager = lazyScreen(
() => import("components/settings/recommendations-manager"),
);
const StreamingCategorySettings = lazyScreen(() =>
import("components/settings/streaming-category-settings").then((m) => ({
default: m.StreamingCategorySettings,
})),
);
const WebhookManager = lazyScreen(
() => import("components/settings/webhook-manager"),
);
const AppReturnScreen = lazyScreen(() => import("src/screens/app-return"));
const PopoutChat = lazyScreen(() => import("src/screens/chat-popout"));
const DanmuOBSScreen = lazyScreen(() => import("src/screens/danmu-obs"));
const DownloadScreen = lazyScreen(() => import("src/screens/download"));
const EmbedScreen = lazyScreen(() => import("src/screens/embed"));
const InfoWidgetEmbed = lazyScreen(
() => import("src/screens/info-widget-embed"),
);
const LaunchGoLive = lazyScreen(() => import("src/screens/launch-go-live"));
const LiveDashboard = lazyScreen(() => import("src/screens/live-dashboard"));
const MobileGoLive = lazyScreen(() => import("src/screens/mobile-go-live"));
const MobileStream = lazyScreen(() => import("src/screens/mobile-stream"));
const MultiScreen = lazyScreen(() => import("src/screens/multi"));
const PopoutInfoWidget = lazyScreen(
() => import("src/screens/popout-info-widget"),
);
const PopoutLivestream = lazyScreen(
() => import("src/screens/popout-livestream"),
);
const PopoutMultistream = lazyScreen(
() => import("src/screens/popout-multistream"),
);
const PopoutStreamMonitor = lazyScreen(
() => import("src/screens/popout-stream-monitor"),
);
const SearchScreen = lazyScreen(() => import("src/screens/search"));
const SupportScreen = lazyScreen(() => import("src/screens/support"));
const UploadScreen = lazyScreen(() => import("src/screens/upload"));
const UploadVideoScreen = lazyScreen(() =>
import("src/screens/upload").then((m) => ({ default: m.UploadVideoScreen })),
);
const UploadDraftsScreen = lazyScreen(() =>
import("src/screens/upload").then((m) => ({ default: m.UploadDraftsScreen })),
);
const UploadLivestreamsScreen = lazyScreen(() =>
import("src/screens/upload").then((m) => ({
default: m.UploadLivestreamsScreen,
})),
);
const UploadVideosScreen = lazyScreen(() =>
import("src/screens/upload").then((m) => ({ default: m.UploadVideosScreen })),
);
const VideoScreen = lazyScreen(() => import("src/screens/video"));
const VideoListScreen = lazyScreen(() => import("src/screens/video-list"));
const VodScreen = lazyScreen(() => import("src/screens/vod"));
const VodEmbedScreen = lazyScreen(() => import("src/screens/vod-embed"));
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
const Tab = createBottomTabNavigator();
const RootStack = createNativeStackNavigator();
const HomeStack = createNativeStackNavigator();
const VideosStack = createNativeStackNavigator();
const SearchStack = createNativeStackNavigator();
const SettingsStack = createNativeStackNavigator();
function useBaseScreenOptions() {
const z = useTheme();
const isLargeScreen = useIsLargeScreen();
return {
headerShown: true,
// Wide headers reserve the title area for Search; retain browser titles.
headerTitle: isLargeScreen ? "" : undefined,
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 && (
)}
);
}
// Shared chrome for tab-root stacks (Videos, Search). Unlike the pushed Home
// screens, these are tab roots, so they always carry their own header.
function useTabRootScreenOptions(): NativeStackNavigationOptions {
const baseScreenOptions = useBaseScreenOptions();
const isNative = Platform.OS !== "web";
return {
...baseScreenOptions,
headerLeft: isNative
? undefined
: ({ canGoBack }: NativeStackHeaderBackProps) => (
),
headerRight: () => (
),
...(Platform.OS === "ios" && {
unstable_headerRightItems: () => [
{
type: "custom",
hidesSharedBackground: true,
element: (
),
},
],
}),
};
}
// Videos stack navigator (global + per-user VOD listings)
function VideosNavigator() {
const screenOptions = useTabRootScreenOptions();
return (
);
}
// Search stack navigator (user search by handle prefix)
function SearchNavigator() {
const screenOptions = useTabRootScreenOptions();
return (
);
}
// 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",
Search: "magnifyingglass",
Settings: "gearshape.fill",
};
const ANDROID_ICONS = {
Home: "home",
Videos: "video_library",
Search: "search",
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 && (
(
),
headerShown: true,
headerTransparent: true,
}}
/>
)}
(
),
}),
headerShown: false,
}}
/>
);
}
// 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;
// Embeds and OBS/popout utilities are not app chrome surfaces.
const showHeaderSearch =
sidebar.isActive &&
!/Embed|^Popout|^DanmuOBS$/.test(currentRouteName ?? "");
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" },
});
}}
>
)}
{showHeaderSearch && }
{
closePdsModal();
loginAction(pdsHost, openLoginLink);
}}
/>
);
}