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 && (
(
),
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;
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);
}}
/>
);
}