Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
35 kB · 1090 lines
TSX
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091import { 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) => ( <NavigationButton canGoBack={canGoBack} /> ), headerRight: () => ( <View style={{ flexDirection: "row", alignItems: "center" }}> <UploadButton /> <LGAvatarButton /> </View> ), ...(isNative && { headerTransparent: true, }), ...(Platform.OS === "ios" && { unstable_headerRightItems: () => [ { type: "custom", hidesSharedBackground: true, element: ( <View style={{ flexDirection: "row", alignItems: "center" }}> <UploadButton /> <LGAvatarButton /> </View> ), }, ], }), headerTitleStyle: { fontFamily: z.theme.typography.universal.xl.fontFamily, fontSize: 15, fontWeight: "500" as const, color: z.theme.colors.text1, }, };
return ( <HomeStack.Navigator screenOptions={baseScreenOptions}> <HomeStack.Screen name="HomeMain" component={HomeScreen} options={{ // `title` drives the browser tab. Narrow headers show Home (or the // node title on iOS); wide headers reserve that space for Search. title, headerTitle: baseScreenOptions.headerTitle ?? (Platform.OS === "ios" ? (props) => ( <View style={{ flex: 1, alignItems: "flex-start" }}> <Text size="2xl" style={[zero.ml[4]]}> {title} </Text> </View> ) : "Home"), headerLeft: Platform.OS !== "ios" ? ({ canGoBack }) => <NavigationButton canGoBack={canGoBack} /> : undefined, headerRight: () => ( <View style={{ flexDirection: "row", alignItems: "center" }}> <UploadButton /> <AvatarButton /> </View> ), ...(Platform.OS === "ios" && { unstable_headerRightItems: () => [ { type: "custom", hidesSharedBackground: true, element: ( <View style={{ flexDirection: "row", alignItems: "center" }}> <UploadButton /> <LGAvatarButton /> </View> ), }, ], }), }} /> <HomeStack.Screen name="Download" component={DownloadScreen} options={{ title: "Download", ...headerScreenOptions }} /> <HomeStack.Screen name="LiveDashboard" component={LiveDashboard} options={{ title: "Live streaming", ...headerScreenOptions }} /> <HomeStack.Screen name="Login" component={Login} options={{ title: did ? "Account" : "Login", ...headerScreenOptions }} /> <HomeStack.Screen name="Multi" component={MultiScreen} options={{ title: "Multi-stream", ...headerScreenOptions }} /> <HomeStack.Screen name="Support" component={SupportScreen} options={{ title: "Support", ...headerScreenOptions }} /> {!isNative && ( <HomeStack.Screen name="Upload" component={UploadScreen} options={{ title: "Upload Video", ...headerScreenOptions }} /> )} {!isNative && ( <HomeStack.Screen name="UploadVideo" component={UploadVideoScreen} options={{ title: "Edit Video", ...headerScreenOptions }} /> )} {!isNative && ( <HomeStack.Screen name="UploadDrafts" component={UploadDraftsScreen} options={{ title: "Drafts", ...headerScreenOptions }} /> )} {!isNative && ( <HomeStack.Screen name="UploadLivestreams" component={UploadLivestreamsScreen} options={{ title: "Livestreams", ...headerScreenOptions }} /> )} {!isNative && ( <HomeStack.Screen name="UploadVideos" component={UploadVideosScreen} options={{ title: "My Videos", ...headerScreenOptions }} /> )} </HomeStack.Navigator> );}
// 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) => ( <NavigationButton canGoBack={canGoBack} /> ), headerRight: () => ( <View style={{ flexDirection: "row", alignItems: "center" }}> <UploadButton /> <LGAvatarButton /> </View> ), ...(Platform.OS === "ios" && { unstable_headerRightItems: () => [ { type: "custom", hidesSharedBackground: true, element: ( <View style={{ flexDirection: "row", alignItems: "center" }}> <UploadButton /> <LGAvatarButton /> </View> ), }, ], }), };}
// Videos stack navigator (global + per-user VOD listings)function VideosNavigator() { const screenOptions = useTabRootScreenOptions();
return ( <VideosStack.Navigator screenOptions={screenOptions}> <VideosStack.Screen name="VideoList" component={VideoListScreen} options={{ title: "Videos" }} /> <VideosStack.Screen name="UserVideoList" component={VideoListScreen} options={{ title: "Videos" }} /> </VideosStack.Navigator> );}
// Search stack navigator (user search by handle prefix)function SearchNavigator() { const screenOptions = useTabRootScreenOptions();
return ( <SearchStack.Navigator screenOptions={screenOptions}> <SearchStack.Screen name="Search" component={SearchScreen} options={{ title: "Search" }} /> </SearchStack.Navigator> );}
// Settings stack navigatorfunction 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: () => ( <View style={{ flexDirection: "row", alignItems: "center" }}> <UploadButton /> <LGAvatarButton /> </View> ), ...(Platform.OS === "ios" && { unstable_headerRightItems: () => [ { type: "custom", hidesSharedBackground: true, element: ( <View style={{ flexDirection: "row", alignItems: "center" }}> <UploadButton /> <LGAvatarButton /> </View> ), }, ], }), }; return ( <SettingsStack.Navigator initialRouteName="MainSettings" screenOptions={{ ...headerScreenOptions, }} > <SettingsStack.Screen name="MainSettings" component={Settings} options={{ title: "Settings" }} /> <SettingsStack.Screen name="AboutCategory" component={AboutCategorySettings} options={{ title: "About" }} /> <SettingsStack.Screen name="AccountCategory" component={AccountCategorySettings} options={{ title: "Account" }} /> <SettingsStack.Screen name="StreamingCategory" component={StreamingCategorySettings} options={{ title: "Streaming" }} /> <SettingsStack.Screen name="WebhooksSettings" component={WebhookManager} options={{ title: "Webhooks" }} /> <SettingsStack.Screen name="BackupSettings" component={BackupSettings} options={{ title: "Backup" }} /> <SettingsStack.Screen name="RecommendationsSettings" component={RecommendationsManager} options={{ title: "Recommendations" }} /> <SettingsStack.Screen name="PrivacyCategory" component={PrivacyCategorySettings} options={{ title: "Privacy & Security" }} /> <SettingsStack.Screen name="NotificationsCategory" component={NotificationsCategorySettings} options={{ title: "Notifications" }} /> <SettingsStack.Screen name="DanmuCategory" component={DanmuCategorySettings} options={{ title: "Danmu" }} /> <SettingsStack.Screen name="AdvancedCategory" component={AdvancedCategorySettings} options={{ title: "Advanced" }} /> <SettingsStack.Screen name="MultistreamCategory" component={MultistreamManager} options={{ title: "Multistream" }} /> <SettingsStack.Screen name="LanguagesCategory" component={LanguagesCategorySettings} options={{ title: "Languages" }} /> <SettingsStack.Screen name="KeyManagement" component={KeyManager} options={{ title: "Key Manager" }} /> <SettingsStack.Screen name="BadgeSelection" component={BadgeSelectionManager} options={{ title: "Badges" }} /> <SettingsStack.Screen name="BadgeIssuer" component={BadgeIssuerPanel} options={{ title: "Issue Badges" }} /> <SettingsStack.Screen name="BrandingAdmin" component={BrandingAdmin} options={{ title: "Branding" }} /> </SettingsStack.Navigator> );}
const IOS_ICONS: Record<string, SFSymbols7_0> = { 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 ( <Tab.Navigator screenOptions={{ lazy: true, headerShown: false, // Hide tab bar on web and < 800px tabBarStyle: isNative ? { backgroundColor: z.theme.colors.surface1, borderTopColor: z.theme.colors.borderSubtle, } : !isLargeScreen ? { backgroundColor: z.theme.colors.surface1, borderTopColor: z.theme.colors.borderSubtle, } : { display: "none" }, tabBarActiveTintColor: accentColor || primaryColor || z.theme.colors.primary, tabBarInactiveTintColor: z.theme.colors.text3, headerTitleStyle: { fontFamily: z.theme.typography.universal["2xl"].fontFamily, }, headerStyle: { backgroundColor: z.theme.colors.background, }, }} > <Tab.Screen name="HomeTab" component={HomeNavigator} options={{ title: "Home", ...(isNative ? { tabBarIcon: getIcon("Home"), } : { tabBarIcon: ({ color, size }) => ( <Home size={size} color={color} /> ), }), }} /> <Tab.Screen name="VideosTab" component={VideosNavigator} options={{ title: "Videos", ...(isNative ? { tabBarIcon: getIcon("Videos"), } : { tabBarIcon: ({ color, size }) => ( <Clapperboard size={size} color={color} /> ), }), }} /> <Tab.Screen name="SearchTab" component={SearchNavigator} options={{ title: "Search", ...(isNative ? { tabBarIcon: getIcon("Search"), } : { tabBarIcon: ({ color, size }) => ( <Search size={size} color={color} /> ), }), }} /> {!isNative && ( <Tab.Screen name="GoLiveTab" component={LaunchGoLive} options={{ title: "Go Live", tabBarIcon: ({ color, size }) => ( <Video size={size} color={color} /> ), headerShown: true, headerTransparent: true, }} /> )} <Tab.Screen name="SettingsTab" component={SettingsNavigator} options={{ title: "Settings", ...(isNative ? { tabBarIcon: getIcon("Settings"), } : { tabBarIcon: ({ color, size }) => ( <Cog size={size} color={color} /> ), }), headerShown: false, }} /> </Tab.Navigator> );}
// 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<string | undefined>( () => { 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 <View />; }
if ((sessionRestoring || !brandingSettled) && !bootTimedOut) { return <View />; }
return ( <View style={{ flex: 1 }}> <StatusBar barStyle="light-content" /> {!isNative && <SidebarOverlay />} {!isNative && <MobileAppBanner />} <Animated.View style={[{ flex: 1 }, animatedContentStyle]}> <RootStack.Navigator screenOptions={{ // Reuse the shared chrome (surface0 + hairline, 15px title) so // pushed screens like Stream don't render a differently-colored // header than the HomeStack. ...baseScreenOptions, headerShown: !isNative, headerLeft: ({ canGoBack }) => ( <NavigationButton canGoBack={canGoBack} /> ), headerRight: () => ( <View style={{ flexDirection: "row", alignItems: "center" }}> <UploadButton /> <LGAvatarButton /> </View> ), ...(isNative && { headerTransparent: true, }), }} > {/* Main tabs (initial screen for all platforms) */} <RootStack.Screen name="MainTabs" component={TabNavigator} options={{ headerShown: false }} />
{/* Full-screen screens that should NOT have tab bar accessible on mobile */} <RootStack.Screen name="Stream" component={MobileStream} options={{ headerShown: Platform.OS === "web", headerTitle: "", }} /> <RootStack.Screen name="MobileGoLive" component={MobileGoLive} options={{ headerShown: false }} /> <RootStack.Screen name="Video" component={VideoScreen} options={{ headerShown: false }} /> <RootStack.Screen name="Vod" component={VodScreen} options={{ headerShown: false }} />
{/* Utility/embed screens */} <RootStack.Screen name="AppReturn" component={AppReturnScreen} options={{ title: "Returning to app..." }} /> <RootStack.Screen name="PopoutChat" component={PopoutChat} options={{ headerShown: false }} /> <RootStack.Screen name="Embed" component={EmbedScreen} options={{ headerShown: false }} /> <RootStack.Screen name="VodEmbed" component={VodEmbedScreen} options={{ headerShown: false }} /> <RootStack.Screen name="InfoWidgetEmbed" component={InfoWidgetEmbed} options={{ headerShown: false }} /> <RootStack.Screen name="DanmuOBS" component={DanmuOBSScreen} options={{ headerShown: false }} /> <RootStack.Screen name="PopoutStreamMonitor" component={PopoutStreamMonitor} options={{ headerShown: false }} /> <RootStack.Screen name="PopoutInfoWidget" component={PopoutInfoWidget} options={{ headerShown: false }} /> <RootStack.Screen name="PopoutMultistream" component={PopoutMultistream} options={{ headerShown: false }} /> <RootStack.Screen name="PopoutLivestream" component={PopoutLivestream} options={{ headerShown: false }} /> </RootStack.Navigator> </Animated.View> {/* Scrim behind the overlay drawer (detail views only) */} {!isNative && ( <AnimatedPressable accessibilityLabel="Close menu" pointerEvents={ sidebar.overlay && sidebar.drawerOpen ? "auto" : "none" } onPress={closeDrawer} style={[ scrimStyle, { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "#000", // eslint-disable-line streamplace/no-token-literals -- overlay scrim zIndex: 127000, }, ]} /> )} {/* 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 && ( <View style={[ { position: "absolute", top: 0, height: 56, flexDirection: "row", alignItems: "center", // Match the sidebar brand row (gap 0) so the logo sits in the // same spot when navigating between the sidebar and detail-view // headers. gap: 0, zIndex: 128001, }, isVodDetail ? // Full-width liquid-glass bar the video scrolls under. ({ left: 0, right: 0, paddingHorizontal: spacing[2], backgroundColor: withAlpha(z.theme.colors.surface0, 0.55), backdropFilter: "blur(18px)", WebkitBackdropFilter: "blur(18px)", borderBottomWidth: 1, borderBottomColor: z.theme.colors.borderSubtle, } as any) : { left: spacing[2] }, ]} > <SidebarToggle label="Open menu" onPress={sidebar.toggle} /> <LogoBrandMenu> <Pressable // @ts-ignore renders as <a> on web href="/" style={{ flexShrink: 1, minWidth: 0 }} onPress={(e: any) => { e?.preventDefault?.(); navigation.navigate("MainTabs" as any, { screen: "HomeTab", params: { screen: "HomeMain" }, }); }} > <SiteTitleLockup size={19} weight="semibold" letterSpacing={0} markColor={colors.white} color={colors.white} /> </Pressable> </LogoBrandMenu> </View> )} {showHeaderSearch && <HeaderSearch key={currentRouteName} />} <LoginModal visible={showLoginModal} onClose={closeLoginModal} onOpenPdsModal={openPdsModal} /> <PdsHostSelectorModal open={showPdsModal} onOpenChange={closePdsModal} onSubmit={(pdsHost) => { closePdsModal(); loginAction(pdsHost, openLoginLink); }} /> <UploadProgressIndicator /> </View> );}