import { LiquidGlassView } from "@callstack/liquid-glass"; import "@expo/metro-runtime"; import { useNavigation } from "@react-navigation/native"; import { AppCrashScreen, Button, DropdownMenu, DropdownMenuItem, DropdownMenuTrigger, ErrorBoundary, IconButton, ResponsiveDropdownMenuContent, Text, useTheme, } from "@streamplace/components"; import { statusColors } from "@streamplace/components/src/lib/theme/tokens"; import { Provider } from "components"; import { ImageBackground } from "expo-image"; import { useLiveUser } from "hooks/useLiveUser"; import { CircleUser, LogIn, LogOut, Plus, Radio, Settings, SquarePlay, User, } from "lucide-react-native"; import { useState } from "react"; import { ImageSourcePropType, Platform, useWindowDimensions, View, } from "react-native"; import AQLink from "../components/aqlink"; import { convertNavigationParams, ROOT_SCREENS } from "./navigation-helper"; import Constants from "expo-constants"; import { configureReanimatedLogger, ReanimatedLogLevel, } from "react-native-reanimated"; import "src/navigation-types"; import Shell from "src/shell"; import { useStore } from "store"; import { useUserProfile } from "store/hooks"; import { getStreamplaceStateFromPath, streamplaceLinkingOptions, } from "./linking-config"; // Initialize sidebar state on app load useStore.getState().loadStateFromStorage(); // On web, seed overlay mode from the initial URL so a direct load of a detail // view (stream/video) renders full-width immediately — no docked-then-snap flash. const DETAIL_ROUTES = ["Stream", "Video", "Vod"]; // getStateFromPath nests routes, so walk the active chain to find the leaf. function pathHasDetailRoute(state: any): boolean { const routes = state?.routes; if (!routes?.length) return false; const active = routes[state.index ?? routes.length - 1]; if (!active) return false; if (DETAIL_ROUTES.includes(active.name)) return true; return pathHasDetailRoute(active.state); } if (Platform.OS === "web" && typeof window !== "undefined") { try { const path = window.location.pathname + window.location.search + window.location.hash; if (pathHasDetailRoute(getStreamplaceStateFromPath(path))) { useStore.getState().setOverlay(true); } } catch { // fall back to the default (docked) — the Shell effect will correct it } } // disabled strict b/c chat swipeable triggers it a LOT and the resulting logging // slows down the whole app configureReanimatedLogger({ level: ReanimatedLogLevel.warn, strict: false, }); const associatedDomain = Constants.expoConfig?.ios?.associatedDomains?.[0]; if (associatedDomain && associatedDomain.startsWith("applinks:")) { const domain = associatedDomain.slice("applinks:".length); streamplaceLinkingOptions.prefixes?.push(`https://${domain}`); } // https://github.com/streamplace/streamplace/issues/377 const hasDevDomain = streamplaceLinkingOptions.prefixes?.some((prefix) => prefix.includes("tv.aquareum.dev"), ); if (hasDevDomain) { streamplaceLinkingOptions.prefixes?.push("tv.aquareum://"); streamplaceLinkingOptions.prefixes?.push("https://stream.place"); } console.log("Linking prefixes", streamplaceLinkingOptions.prefixes); export default function Router() { // The last line of defense: a render error anywhere below would otherwise // unmount the whole tree and leave a black window with nothing to click. return ( }> ); } // Just a small left inset so the header title clears the sidebar. Back // navigation is handled by the sidebar and the browser's own back button, so // there's no in-header back arrow. export const NavigationButton = (_props: { canGoBack?: boolean }) => { return ; }; export const LGAvatarButton = () => { const userProfile = useUserProfile(); const userIsLive = useLiveUser(); if (Platform.OS !== "ios") return ; let source: ImageSourcePropType | undefined = undefined; let opacity = 1; // On-air, the avatar links to the live dashboard (with a red ring for the // state); otherwise it goes to the account screen as usual. const targetScreen: any = userProfile ? userIsLive ? { screen: "LiveDashboard", params: {} } : { screen: "AccountCategory", params: {} } : { screen: "Login", params: {} }; if (userProfile) { source = { uri: userProfile.avatar }; opacity = 0; } const glassAvatar = ( ); return ( {userProfile && userIsLive ? ( {glassAvatar} ) : ( glassAvatar )} ); }; // One row of the account menu: icon + label, quiet at rest and brightening on // hover. `danger` renders it as red ink with a red-tinted hover (log out). // Like the Create menu, the row's fill is also the keyboard focus cue — the // app's global 2px offset :focus-visible ring floats a rectangle around the // row, so it's suppressed here and Radix's item focus drives the same fill. const AccountMenuItem = ({ icon: Icon, label, onPress, danger, iconColor, }: { icon: typeof User; label: string; onPress: () => void; danger?: boolean; /** Explicit icon tint (e.g. live red for the on-air row); label keeps the row color. */ iconColor?: string; }) => { const { theme } = useTheme(); const c = theme.colors; const [hovered, setHovered] = useState(false); const [focused, setFocused] = useState(false); const active = hovered || focused; const fg = danger ? c.danger : active ? c.text1 : c.text2; return ( setFocused(true)} onBlur={() => setFocused(false)} style={{ outlineStyle: "none" } as any} > setHovered(true)} onPointerLeave={() => setHovered(false)} style={{ flexDirection: "row", alignItems: "center", gap: 12, width: "100%", paddingVertical: 8, paddingHorizontal: 8, borderRadius: 8, backgroundColor: active ? danger ? c.dangerSoft : c.surface3 : "transparent", }} > {label} ); }; export const AvatarButton = () => { const userProfile = useUserProfile(); const userIsLive = useLiveUser(); const openLoginModal = useStore((state) => state.openLoginModal); const openPDSModal = useStore((state) => state.openPdsModal); const logout = useStore((state) => state.logout); const navigation = useNavigation(); const { theme } = useTheme(); const c = theme.colors; const [menuOpen, setMenuOpen] = useState(false); let source: ImageSourcePropType | undefined = undefined; const windowWidth = useWindowDimensions().width; const isCompact = windowWidth <= 800; // Mirror AQLink's navigation: root-level screens (Stream) go through the root // navigator; the rest are nested into their tab (Settings → SettingsTab). const go = (screen: string, params?: any) => () => { if (ROOT_SCREENS.includes(screen)) { const rootNav = navigation.getParent()?.getParent() || navigation; (rootNav as any).navigate(screen, params); } else { const converted = convertNavigationParams({ screen: screen as any, params, }); (navigation as any).navigate(converted.screen, converted.params); } }; if (userProfile) { source = userProfile.avatar ? { uri: userProfile.avatar } : undefined; const handle = userProfile.handle; const name = userProfile.displayName?.trim() || handle; const channelUser = handle || userProfile.did; const avatar = ( ); return ( {userIsLive ? ( // On-air: the red ring replaces the old "You are live!" toast. {avatar} ) : ( avatar )} {/* Identity — which account you're signed in as */} {name} {handle ? ( @{handle} ) : null} {userIsLive && ( )} logout()} /> ); } if (isCompact) { return ( openLoginModal()} size="sm" accessibilityLabel="Log in" style={{ marginRight: 10, marginLeft: "auto" }} > ); } return ( ); }; // One row of the Create menu. A menu row is a list item, not a card: one // highlight, one level of nesting. An icon in a bordered tile made three nested // rounded rectangles (panel → row → tile), so on hover the wash landed *behind* // a dark bordered square and the tile read as a hole instead of the row reading // as one selected unit. Plain icon instead, and hover/focus moves the icon and // the background together so the whole row responds as a unit. // // The highlight (surface3 on the surface2 panel — the token's documented // "hovered overlay row") is also the keyboard focus cue: the app's global 2px // offset focus ring floats a rectangle around the row, so it's suppressed here // and Radix's item focus drives the same fill as pointer hover. const CreateMenuItem = ({ icon: Icon, title, subtitle, onPress, }: { icon: typeof Plus; title: string; subtitle: string; onPress: () => void; }) => { const { theme } = useTheme(); const c = theme.colors; const [hovered, setHovered] = useState(false); const [focused, setFocused] = useState(false); const active = hovered || focused; return ( setFocused(true)} onBlur={() => setFocused(false)} style={{ outlineStyle: "none" } as any} > setHovered(true)} onPointerLeave={() => setHovered(false)} style={{ flexDirection: "row", alignItems: "center", gap: 12, // flex:1 — NOT width:"100%". A fixed 100% width plus the negative // margins below just shifts the box left (it gains 8px on the left and // loses 8px on the right); as a flex item the negative margins are // absorbed into the layout so it widens evenly on both sides. flex: 1, paddingVertical: 8, paddingHorizontal: 10, borderRadius: 8, // DropdownMenuItem wraps children in its own pl[2]/pr[2]/py[1], which // stacks on the panel's p[2] and floats the highlight ~16px in from // the panel edge. Cancel that inner padding so the gutter is just the // panel's own 8px. marginHorizontal: -8, marginVertical: -4, backgroundColor: active ? c.surface3 : "transparent", }} > {title} {subtitle} ); }; // YouTube-style web "Create" CTA: a pill that opens a menu to upload a VOD or // go live. Native release builds temporarily hide this entry point. export const UploadButton = () => { const did = useStore((state) => state.oauthSession?.did); const { theme } = useTheme(); const c = theme.colors; const windowWidth = useWindowDimensions().width; const isCompact = windowWidth <= 800; const navigation = useNavigation(); // Observed only for the trigger's active styling — the menu is uncontrolled // so DropdownMenuItem closes the portal itself on press (no orphaned menu). const [menuOpen, setMenuOpen] = useState(false); if (Platform.OS !== "web" || !did) return null; const go = (screen: string) => () => navigation.navigate("HomeTab" as any, { screen }); return ( {isCompact ? ( ) : ( )} ); };