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 ? (
) : (
}
// Keep the trigger visibly "active" while its menu is open.
style={menuOpen ? { backgroundColor: c.surface3 } : undefined}
>
Create
)}
);
};