refactor(native): centralize navigation header configuration into dedicated stack components

Replace the `useTabScreenOptions` hook (and its `.android` variant) with a `TabStack` component that encapsulates all tab-level header styling in one place. Introduce `AuthStackHeader` and `DetailStackHeader` components to own header configuration for auth and detail screens respectively, removing scattered `Stack.Screen options` overrides from individual screen files.

- Delete `use-tab-screen-options.tsx` and `use-tab-screen-options.android.tsx`; all four tab layouts now render `<TabStack title="…" />` instead of manually composing `<Stack screenOptions={useTabScreenOptions()}>`.
- Add `AuthStackHeader` and swap every `<Stack.Screen options={{ title: "…" }}>` inside auth screens for it; strip the now-redundant header option overrides from the auth `_layout`.
- Add `DetailStackHeader` and remove the hardcoded `headerTransparent`/`headerTintColor`/`headerBackButtonDisplayMode` options from the title and person detail routes in `_layout.tsx`.
- Replace remaining `Stack.Screen options={{ title }}` patterns in non-tab screens (`change-password`, `+not-found`) with the `Stack.Screen.Title` helper.
- Refactor the search screen to use `Stack.Header`, `Stack.Screen.Title`, and `Stack.SearchBar` instead of an inline `Stack.Screen options` object; add `placement` and `allowToolbarIntegration` for the iOS integrated search bar.
- Add `browseTitleIds` server procedure and extend `explore` / `discover` procedures; add corresponding schema fields in `@sofa/api`.
This commit is contained in:
2026-03-16 15:14:32 -04:00
parent e5fc18178b
commit ccc7922737
33 changed files with 367 additions and 241 deletions
+1 -10
View File
@@ -1,5 +1,5 @@
import { Stack } from "expo-router";
import { useCSSVariable, useResolveClassNames } from "uniwind";
import { useResolveClassNames } from "uniwind";
import { hasStoredServerUrl } from "@/lib/server-url";
export const unstable_settings = {
@@ -10,20 +10,11 @@ export const unstable_settings = {
};
export default function AuthLayout() {
const headerTitleStyle = useResolveClassNames(
"font-display text-base text-foreground",
);
const contentStyle = useResolveClassNames("bg-background");
const tintColor = useCSSVariable("--color-primary") as string;
return (
<Stack
screenOptions={{
headerTransparent: true,
headerShadowVisible: false,
headerBackButtonDisplayMode: "minimal",
headerTitleStyle: headerTitleStyle as Record<string, unknown>,
headerTintColor: tintColor,
contentStyle,
animation: "fade",
}}
+3 -2
View File
@@ -1,11 +1,12 @@
import { useForm } from "@tanstack/react-form";
import { useQuery } from "@tanstack/react-query";
import { Link, Stack } from "expo-router";
import { Link } from "expo-router";
import { useRef } from "react";
import { Alert, Pressable, type TextInput, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { z } from "zod";
import { AuthScreen } from "@/components/auth-screen";
import { AuthStackHeader } from "@/components/navigation/auth-stack-header";
import { Button, ButtonLabel } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
@@ -73,7 +74,7 @@ export default function LoginScreen() {
return (
<AuthScreen title="Sofa" subtitle="Sign in to continue">
<Stack.Screen options={{ title: "Sign In" }} />
<AuthStackHeader title="Sign In" />
{showOidc && (
<Animated.View
entering={FadeInDown.duration(300).delay(100)}
+4 -3
View File
@@ -1,11 +1,12 @@
import { useForm } from "@tanstack/react-form";
import { useQuery } from "@tanstack/react-query";
import { Link, Stack } from "expo-router";
import { Link } from "expo-router";
import { useRef } from "react";
import { Alert, Pressable, type TextInput, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { z } from "zod";
import { AuthScreen } from "@/components/auth-screen";
import { AuthStackHeader } from "@/components/navigation/auth-stack-header";
import { Button, ButtonLabel } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
@@ -90,7 +91,7 @@ export default function RegisterScreen() {
title="Registration Closed"
subtitle="New account creation is currently disabled."
>
<Stack.Screen options={{ title: "Registration Closed" }} />
<AuthStackHeader title="Registration Closed" />
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<Link href="/(auth)/login" asChild>
<Button className="mt-6 bg-primary">
@@ -106,7 +107,7 @@ export default function RegisterScreen() {
return (
<AuthScreen title="Create Account">
<Stack.Screen options={{ title: "Create Account" }} />
<AuthStackHeader title="Create Account" />
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
+3 -2
View File
@@ -3,7 +3,7 @@ import {
IconCircleCheck,
IconInfoCircle,
} from "@tabler/icons-react-native";
import { Stack, useRouter } from "expo-router";
import { useRouter } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { Linking, Pressable, TextInput, View } from "react-native";
import Animated, {
@@ -16,6 +16,7 @@ import Animated, {
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { AuthScreen } from "@/components/auth-screen";
import { AuthStackHeader } from "@/components/navigation/auth-stack-header";
import { Button, ButtonLabel } from "@/components/ui/button";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
@@ -152,7 +153,7 @@ export default function ServerUrlScreen() {
subtitle="Enter your Sofa server URL to get started"
logoStyle={iconAnimatedStyle}
>
<Stack.Screen options={{ title: "Server" }} />
<AuthStackHeader title="Server" />
<Animated.View entering={FadeInDown.duration(300).delay(200)}>
<View
className="min-h-12 flex-row items-center rounded-[12px] border border-border bg-input px-3.5"
@@ -1,6 +1,5 @@
import { Stack } from "expo-router";
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
import { TabStack } from "@/components/navigation/tab-stack";
export default function ExploreLayout() {
return <Stack screenOptions={useTabScreenOptions()} />;
return <TabStack title="Explore" />;
}
@@ -1,6 +1,5 @@
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Stack } from "expo-router";
import { useCallback, useMemo } from "react";
import { RefreshControl, ScrollView, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
@@ -83,7 +82,6 @@ export default function ExploreScreen() {
/>
}
>
<Stack.Screen options={{ title: "Explore" }} />
<View className="gap-8">
{heroItem && (
<Animated.View entering={FadeIn.duration(400)}>
@@ -1,6 +1,5 @@
import { Stack } from "expo-router";
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
import { TabStack } from "@/components/navigation/tab-stack";
export default function HomeLayout() {
return <Stack screenOptions={useTabScreenOptions()} />;
return <TabStack title="Home" />;
}
+1 -2
View File
@@ -5,7 +5,7 @@ import {
IconThumbUp,
} from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { Stack, useRouter } from "expo-router";
import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import { RefreshControl, ScrollView, View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
@@ -91,7 +91,6 @@ export default function DashboardScreen() {
/>
}
>
<Stack.Screen options={{ title: "Home" }} />
<View className="gap-8">
{/* Stats */}
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
@@ -1,10 +1,5 @@
import { Stack } from "expo-router";
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
import { TabStack } from "@/components/navigation/tab-stack";
export default function SearchLayout() {
return (
<Stack screenOptions={useTabScreenOptions()}>
<Stack.Screen name="index" options={{ title: "Search" }} />
</Stack>
);
return <TabStack />;
}
+16 -11
View File
@@ -142,17 +142,22 @@ export default function SearchScreen() {
return (
<View className="flex-1 bg-background">
<Stack.Screen
options={{
headerTransparent: false,
headerStyle: { backgroundColor: "#000" },
headerLargeStyle: { backgroundColor: "#000" },
headerSearchBarOptions: {
placeholder: "Search movies, shows, people...",
onChangeText: (e) => setQuery(e.nativeEvent.text),
hideWhenScrolling: false,
},
}}
<Stack.Header
transparent={false}
style={{ backgroundColor: "#000" }}
largeStyle={{ backgroundColor: "#000" }}
/>
<Stack.Screen.Title>Search</Stack.Screen.Title>
<Stack.SearchBar
placeholder="Search movies, shows, people..."
onChangeText={(e) => setQuery(e.nativeEvent.text)}
onCancelButtonPress={() => setQuery("")}
onClose={() => setQuery("")}
hideWhenScrolling={false}
placement={process.env.EXPO_OS === "ios" ? "integrated" : undefined}
allowToolbarIntegration={
process.env.EXPO_OS === "ios" ? true : undefined
}
/>
{debouncedQuery.length === 0 ? (
@@ -1,6 +1,5 @@
import { Stack } from "expo-router";
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
import { TabStack } from "@/components/navigation/tab-stack";
export default function SettingsLayout() {
return <Stack screenOptions={useTabScreenOptions()} />;
return <TabStack title="Settings" />;
}
@@ -20,7 +20,7 @@ import {
import { useMutation, useQuery } from "@tanstack/react-query";
import * as Application from "expo-application";
import * as ImagePicker from "expo-image-picker";
import { Stack, useRouter } from "expo-router";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
@@ -218,8 +218,6 @@ export default function SettingsScreen() {
/>
}
>
<Stack.Screen options={{ title: "Settings" }} />
{/* Account */}
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<SettingsSection title="Account" icon={IconUser}>
+1 -1
View File
@@ -12,7 +12,7 @@ export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "Not Found" }} />
<Stack.Screen.Title>Not Found</Stack.Screen.Title>
<Container>
<View className="flex-1 items-center justify-center p-4">
<Animated.View
-10
View File
@@ -67,7 +67,6 @@ export const unstable_settings = {
const changePasswordOptions =
process.env.EXPO_OS === "ios"
? {
title: "Change Password",
headerShown: true,
presentation: "formSheet" as const,
sheetAllowedDetents: "fitToContents" as const,
@@ -77,7 +76,6 @@ const changePasswordOptions =
headerBlurEffect: "none" as const,
}
: {
title: "Change Password",
headerShown: true,
presentation: "modal" as const,
headerLargeTitle: false,
@@ -239,10 +237,6 @@ function AppContent() {
dangerouslySingular
options={{
headerShown: true,
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
animation: "slide_from_right",
}}
/>
@@ -251,10 +245,6 @@ function AppContent() {
dangerouslySingular
options={{
headerShown: true,
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
animation: "slide_from_right",
}}
/>
+1 -1
View File
@@ -92,7 +92,7 @@ export default function ChangePasswordScreen() {
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
>
<Stack.Screen options={{ title: "Change Password" }} />
<Stack.Screen.Title>Change Password</Stack.Screen.Title>
<form.Subscribe
selector={(state) => ({
+6 -37
View File
@@ -10,7 +10,7 @@ import {
import { useInfiniteQuery } from "@tanstack/react-query";
import { format } from "date-fns/format";
import { parseISO } from "date-fns/parseISO";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useMemo } from "react";
import {
ActivityIndicator,
@@ -21,6 +21,7 @@ import {
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind";
import { DetailStackHeader } from "@/components/navigation/detail-stack-header";
import { ExpandableText } from "@/components/ui/expandable-text";
import { Image } from "@/components/ui/image";
import { PosterCard } from "@/components/ui/poster-card";
@@ -164,15 +165,7 @@ export default function PersonDetailScreen() {
if (isPending) {
return (
<>
<Stack.Screen
options={{
title: "",
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
}}
/>
<DetailStackHeader />
<View
className="flex-1 items-center bg-background"
style={{ paddingTop: headerHeight + 24 }}
@@ -207,15 +200,7 @@ export default function PersonDetailScreen() {
if (isError && !data) {
return (
<>
<Stack.Screen
options={{
title: "",
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
}}
/>
<DetailStackHeader />
<View
className="flex-1 items-center justify-center bg-background"
style={{ paddingTop: insets.top }}
@@ -243,15 +228,7 @@ export default function PersonDetailScreen() {
if (!person) {
return (
<>
<Stack.Screen
options={{
title: "",
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
}}
/>
<DetailStackHeader />
<View
className="flex-1 items-center justify-center bg-background"
style={{ paddingTop: insets.top }}
@@ -363,15 +340,7 @@ export default function PersonDetailScreen() {
return (
<>
<Stack.Screen
options={{
title: "",
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
}}
/>
<DetailStackHeader title={person.name} />
<View className="flex-1 bg-background">
<FlashList
data={filmography}
+18 -39
View File
@@ -14,7 +14,7 @@ import {
import { useMutation, useQuery } from "@tanstack/react-query";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { LinearGradient } from "expo-linear-gradient";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { Link, useLocalSearchParams, useRouter } from "expo-router";
import * as WebBrowser from "expo-web-browser";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Pressable, ScrollView, StyleSheet, View } from "react-native";
@@ -25,6 +25,7 @@ import {
HorizontalPosterRow,
type PosterRowItem,
} from "@/components/dashboard/horizontal-poster-row";
import { DetailStackHeader } from "@/components/navigation/detail-stack-header";
import { CastCard } from "@/components/titles/cast-card";
import { ContinueWatchingBanner } from "@/components/titles/continue-watching-banner";
import { SeasonAccordion } from "@/components/titles/season-accordion";
@@ -284,15 +285,7 @@ export default function TitleDetailScreen() {
if (detail.isPending) {
return (
<>
<Stack.Screen
options={{
title: "",
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
}}
/>
<DetailStackHeader />
<View className="flex-1 bg-background">
{/* Hero skeleton */}
<Skeleton width="100%" height={300} borderRadius={0} />
@@ -322,15 +315,7 @@ export default function TitleDetailScreen() {
if (!title) {
return (
<>
<Stack.Screen
options={{
title: "",
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
}}
/>
<DetailStackHeader />
<View className="flex-1 items-center justify-center bg-background px-6">
<IconMovie size={48} color={mutedForeground} />
<Text className="mt-3 font-display text-foreground text-xl">
@@ -348,15 +333,7 @@ export default function TitleDetailScreen() {
return (
<>
<Stack.Screen
options={{
title: "",
headerTransparent: true,
headerBlurEffect: "none",
headerTintColor: "white",
headerBackButtonDisplayMode: "minimal",
}}
/>
<DetailStackHeader title={title.title} />
<ScrollView
className="bg-background"
contentInsetAdjustmentBehavior={
@@ -424,17 +401,19 @@ export default function TitleDetailScreen() {
<View className="absolute right-0 bottom-0 left-0 flex-row items-end p-4">
{title.posterPath && (
<View
className="mr-3 h-[150px] w-[100px] overflow-hidden rounded-lg"
style={[{ borderCurve: "continuous" }, posterShadowStyle]}
>
<Image
source={{ uri: title.posterPath }}
thumbHash={title.posterThumbHash}
style={titleDetailStyles.posterImage}
contentFit="cover"
/>
</View>
<Link.AppleZoomTarget>
<View
className="mr-3 h-[150px] w-[100px] overflow-hidden rounded-lg"
style={[{ borderCurve: "continuous" }, posterShadowStyle]}
>
<Image
source={{ uri: title.posterPath }}
thumbHash={title.posterThumbHash}
style={titleDetailStyles.posterImage}
contentFit="cover"
/>
</View>
</Link.AppleZoomTarget>
)}
<View className="flex-1 pb-1">
<Text
@@ -58,7 +58,7 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
return (
<Link href={`/title/${item.title.id}` as `/title/${string}`}>
<Link.Trigger>
<Link.Trigger withAppleZoom>
<GestureDetector gesture={tapGesture}>
<Animated.View
accessible
@@ -28,10 +28,12 @@ export function FilterableTitleRow({
icon: Icon;
mediaType: "movie" | "tv";
defaultItems: Array<{
id?: string;
tmdbId: number;
title: string;
type: string;
posterPath: string | null;
posterThumbHash?: string | null;
releaseDate?: string | null;
firstAirDate?: string | null;
voteAverage?: number | null;
@@ -1,7 +1,7 @@
import { IconStarFilled } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { useRouter } from "expo-router";
import { Link, useRouter } from "expo-router";
import { useCallback } from "react";
import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
@@ -19,6 +19,7 @@ import { orpc } from "@/lib/orpc";
import { toast } from "@/lib/toast";
export interface HeroBannerItem {
id?: string;
tmdbId: number;
title: string;
type: string;
@@ -73,8 +74,11 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
]
.filter(Boolean)
.join(", ");
const titleHref = item.id
? (`/title/${item.id}` as `/title/${string}`)
: null;
return (
const bannerContent = (
<GestureDetector gesture={tapGesture}>
<Animated.View
className="mx-4 overflow-hidden rounded-2xl"
@@ -88,7 +92,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
]}
>
<Pressable
onPress={handlePress}
onPress={titleHref ? undefined : handlePress}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details"
@@ -179,4 +183,15 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
</Animated.View>
</GestureDetector>
);
if (titleHref) {
return (
<Link href={titleHref}>
<Link.Trigger>{bannerContent}</Link.Trigger>
<Link.Preview />
</Link>
);
}
return bannerContent;
}
@@ -1,6 +1,6 @@
import { IconStarFilled } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { Link, useRouter } from "expo-router";
import { useCallback } from "react";
import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
@@ -19,6 +19,7 @@ import { orpc } from "@/lib/orpc";
import { toast } from "@/lib/toast";
export interface HeroBannerItem {
id?: string;
tmdbId: number;
title: string;
type: string;
@@ -72,8 +73,11 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
]
.filter(Boolean)
.join(", ");
const titleHref = item.id
? (`/title/${item.id}` as `/title/${string}`)
: null;
return (
const bannerContent = (
<GestureDetector gesture={tapGesture}>
<Animated.View
className="mx-4 overflow-hidden rounded-2xl"
@@ -87,7 +91,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
]}
>
<Pressable
onPress={handlePress}
onPress={titleHref ? undefined : handlePress}
accessible
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
@@ -135,4 +139,15 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
</Animated.View>
</GestureDetector>
);
if (titleHref) {
return (
<Link href={titleHref}>
<Link.Trigger>{bannerContent}</Link.Trigger>
<Link.Preview />
</Link>
);
}
return bannerContent;
}
@@ -0,0 +1,22 @@
import { Stack } from "expo-router";
import { useCSSVariable, useResolveClassNames } from "uniwind";
export function AuthStackHeader({ title }: { title: string }) {
const headerTitleStyle = useResolveClassNames(
"font-display text-base text-foreground",
);
const tintColor = useCSSVariable("--color-primary") as string;
return (
<>
<Stack.Header
transparent
style={{ color: tintColor, shadowColor: "transparent" }}
/>
<Stack.Screen.BackButton displayMode="minimal" />
<Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}>
{title}
</Stack.Screen.Title>
</>
);
}
@@ -0,0 +1,14 @@
import { Stack } from "expo-router";
export function DetailStackHeader({ title }: { title?: string }) {
return (
<>
<Stack.Header transparent blurEffect="none" style={{ color: "white" }} />
<Stack.Screen.BackButton
displayMode="minimal"
withMenu={process.env.EXPO_OS === "ios"}
/>
{title ? <Stack.Screen.Title>{title}</Stack.Screen.Title> : null}
</>
);
}
@@ -1,9 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import {
NativeTabs,
type NativeTabsProps,
} from "expo-router/unstable-native-tabs";
import { useMemo } from "react";
import { useCSSVariable, useResolveClassNames } from "uniwind";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
import * as Haptics from "@/utils/haptics";
export function NativeTabBar() {
@@ -11,6 +14,14 @@ export function NativeTabBar() {
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const surfaceColor = useCSSVariable("--color-card") as string;
const rippleColor = useCSSVariable("--color-secondary") as string;
const { data: session } = authClient.useSession();
const isAdmin = session?.user?.role === "admin";
const updateCheck = useQuery({
...orpc.admin.updateCheck.queryOptions(),
enabled: isAdmin,
staleTime: 10 * 60 * 1000,
});
const showSettingsBadge = !!updateCheck.data?.updateCheck?.updateAvailable;
const screenListeners = useMemo<NativeTabsProps["screenListeners"]>(
() => ({
@@ -45,21 +56,24 @@ export function NativeTabBar() {
rippleColor={rippleColor}
screenListeners={screenListeners}
>
<NativeTabs.Trigger name="(home)">
<NativeTabs.Trigger name="(home)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(explore)">
<NativeTabs.Trigger name="(explore)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon md="explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)">
<NativeTabs.Trigger name="(search)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon md="search" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(settings)">
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon md="settings" />
{showSettingsBadge ? (
<NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge>
) : null}
</NativeTabs.Trigger>
</NativeTabs>
);
@@ -1,8 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { Stack } from "expo-router";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useState } from "react";
import { useCSSVariable } from "uniwind";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
const tabTitles: Record<string, string> = {
"(home)": "Home",
@@ -15,6 +18,14 @@ export function NativeTabBar() {
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
const [activeTitle, setActiveTitle] = useState("Home");
const { data: session } = authClient.useSession();
const isAdmin = session?.user?.role === "admin";
const updateCheck = useQuery({
...orpc.admin.updateCheck.queryOptions(),
enabled: isAdmin,
staleTime: 10 * 60 * 1000,
});
const showSettingsBadge = !!updateCheck.data?.updateCheck?.updateAvailable;
return (
<>
@@ -37,19 +48,26 @@ export function NativeTabBar() {
},
})}
>
<NativeTabs.Trigger name="(home)">
<NativeTabs.Trigger name="(home)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(explore)">
<NativeTabs.Trigger name="(explore)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="safari" md="explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(settings)">
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
{showSettingsBadge ? (
<NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge>
) : null}
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search" />
<NativeTabs.Trigger
name="(search)"
role="search"
disableTransparentOnScrollEdge
/>
</NativeTabs>
</>
);
@@ -0,0 +1,101 @@
import { Stack } from "expo-router";
import type { ReactNode } from "react";
import { useCSSVariable, useResolveClassNames } from "uniwind";
import { HeaderAvatar } from "@/components/header-avatar";
const hiddenScrollEdgeEffects = {
top: "hidden" as const,
bottom: "hidden" as const,
left: "hidden" as const,
right: "hidden" as const,
};
export function TabStack({
title,
children,
}: {
title?: string;
children?: ReactNode;
}) {
const contentStyle = useResolveClassNames("bg-background");
const tintColor = useCSSVariable("--color-primary") as string;
const backgroundColor = useCSSVariable("--color-background") as string;
const iosHeaderLargeTitleStyle = useResolveClassNames(
"font-display text-foreground",
);
const iosHeaderTitleStyle = useResolveClassNames(
"font-display text-foreground text-lg",
);
const androidHeaderTitleStyle = useResolveClassNames(
"font-sans font-semibold text-foreground text-lg",
);
if (process.env.EXPO_OS === "ios") {
return (
<Stack
screenOptions={{
contentStyle,
scrollEdgeEffects: hiddenScrollEdgeEffects,
unstable_headerRightItems: () => [
{
type: "custom" as const,
element: <HeaderAvatar />,
hidesSharedBackground: true,
},
],
}}
>
{title ? (
<Stack.Screen name="index">
<Stack.Header
transparent
blurEffect="dark"
style={{ color: tintColor, shadowColor: "transparent" }}
largeStyle={{
backgroundColor: "transparent",
shadowColor: "transparent",
}}
/>
<Stack.Screen.Title
large
style={iosHeaderTitleStyle as Record<string, unknown>}
largeStyle={iosHeaderLargeTitleStyle as Record<string, unknown>}
>
{title}
</Stack.Screen.Title>
</Stack.Screen>
) : null}
{children}
</Stack>
);
}
return (
<Stack
screenOptions={{
contentStyle,
headerTitleAlign: "left",
headerRight: () => <HeaderAvatar />,
}}
>
{title ? (
<Stack.Screen name="index">
<Stack.Header
transparent={false}
style={{
backgroundColor,
color: tintColor,
shadowColor: "transparent",
}}
/>
<Stack.Screen.Title
style={androidHeaderTitleStyle as Record<string, unknown>}
>
{title}
</Stack.Screen.Title>
</Stack.Screen>
) : null}
{children}
</Stack>
);
}
+14 -7
View File
@@ -8,6 +8,7 @@ import {
IconPlus,
IconStarFilled,
} from "@tabler/icons-react-native";
import { Link } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
@@ -272,19 +273,25 @@ export function PosterCard({
// Cards with id: use context menu with navigation
if (id) {
const titleHref = `/title/${id}` as `/title/${string}`;
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}>
<View>
<Pressable
onPress={handlePressAction}
accessibilityRole="button"
accessibilityLabel={cardAccessibilityLabel}
>
{cardContent}
</Pressable>
<Link href={titleHref}>
<Link.Trigger withAppleZoom>
<Pressable
accessibilityRole="button"
accessibilityLabel={cardAccessibilityLabel}
>
{cardContent}
</Pressable>
</Link.Trigger>
<Link.Preview />
</Link>
{quickAddButton}
</View>
</Animated.View>
@@ -1,29 +0,0 @@
import type { Stack } from "expo-router";
import type { ComponentProps } from "react";
import { useCSSVariable, useResolveClassNames } from "uniwind";
import { HeaderAvatar } from "@/components/header-avatar";
type ScreenOptions = NonNullable<
Extract<ComponentProps<typeof Stack>["screenOptions"], object>
>;
export function useTabScreenOptions() {
const backgroundColor = useCSSVariable("--color-background") as string;
const headerTitleStyle = useResolveClassNames(
"font-sans font-semibold text-foreground text-lg",
);
const contentStyle = useResolveClassNames("bg-background");
return {
headerLargeTitle: false,
headerTransparent: false,
headerStyle: { backgroundColor },
headerTitleAlign: "left" as const,
headerTitleStyle: headerTitleStyle as Record<string, unknown>,
headerTintColor: useCSSVariable("--color-primary") as string,
headerShadowVisible: false,
headerBackButtonDisplayMode: "minimal" as const,
headerRight: () => <HeaderAvatar />,
contentStyle,
} satisfies ScreenOptions;
}
@@ -1,45 +0,0 @@
import type { Stack } from "expo-router";
import type { ComponentProps } from "react";
import { useCSSVariable, useResolveClassNames } from "uniwind";
import { HeaderAvatar } from "@/components/header-avatar";
type ScreenOptions = NonNullable<
Extract<ComponentProps<typeof Stack>["screenOptions"], object>
>;
export function useTabScreenOptions() {
const headerLargeTitleStyle = useResolveClassNames(
"font-display text-foreground",
);
const headerTitleStyle = useResolveClassNames(
"font-display text-foreground text-lg",
);
const contentStyle = useResolveClassNames("bg-background");
return {
headerLargeTitle: true,
headerTransparent: true,
headerBlurEffect: "dark" as const,
headerLargeStyle: { backgroundColor: "transparent" },
headerLargeTitleStyle: headerLargeTitleStyle as Record<string, unknown>,
headerTitleStyle: headerTitleStyle as Record<string, unknown>,
headerTintColor: useCSSVariable("--color-primary") as string,
headerShadowVisible: false,
headerLargeTitleShadowVisible: false,
headerBackButtonDisplayMode: "minimal" as const,
scrollEdgeEffects: {
top: "hidden" as const,
bottom: "hidden" as const,
left: "hidden" as const,
right: "hidden" as const,
},
unstable_headerRightItems: () => [
{
type: "custom" as const,
element: <HeaderAvatar />,
hidesSharedBackground: true,
},
],
contentStyle,
} satisfies ScreenOptions;
}
@@ -0,0 +1,35 @@
import { db } from "@sofa/db/client";
import { inArray } from "@sofa/db/helpers";
import { titles } from "@sofa/db/schema";
interface BrowseTitleLookup {
tmdbId: number;
type: "movie" | "tv";
}
export function getBrowseTitleIds(
lookups: BrowseTitleLookup[],
): Record<string, string> {
if (lookups.length === 0) return {};
const rows = db
.select({
id: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
})
.from(titles)
.where(
inArray(
titles.tmdbId,
lookups.map((lookup) => lookup.tmdbId),
),
)
.all();
const idsByLookup: Record<string, string> = {};
for (const row of rows) {
idsByLookup[`${row.tmdbId}-${row.type}`] = row.id;
}
return idsByLookup;
}
@@ -12,6 +12,7 @@ import {
browseLookupKey,
getBrowsePosterThumbHashes,
} from "./browse-thumbhashes";
import { getBrowseTitleIds } from "./browse-title-ids";
export const discover = os.discover
.use(authed)
@@ -50,9 +51,13 @@ export const discover = os.discover
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: r.vote_average ?? null,
}));
const titleIdsByLookup = getBrowseTitleIds(
baseItems.map((item) => ({ tmdbId: item.tmdbId, type: item.type })),
);
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
const items = baseItems.map((item) => ({
...item,
id: titleIdsByLookup[browseLookupKey(item)],
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
+26 -6
View File
@@ -12,6 +12,7 @@ import {
browseLookupKey,
getBrowsePosterThumbHashes,
} from "./browse-thumbhashes";
import { getBrowseTitleIds } from "./browse-title-ids";
function requireTmdb() {
if (!isTmdbConfigured()) {
@@ -49,18 +50,33 @@ export const trending = os.explore.trending
voteAverage: (r.vote_average as number | undefined) ?? null,
};
});
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
const items = baseItems.map((item) => ({
...item,
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
const heroResult = results.find(
(r) =>
r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
);
const titleIdsByLookup = getBrowseTitleIds([
...baseItems.map((item) => ({ tmdbId: item.tmdbId, type: item.type })),
...(heroResult
? [
{
tmdbId: heroResult.id as number,
type: heroResult.media_type as "movie" | "tv",
},
]
: []),
]);
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
const items = baseItems.map((item) => ({
...item,
id: titleIdsByLookup[browseLookupKey(item)],
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
const hero = heroResult
? {
id: titleIdsByLookup[
`${heroResult.id as number}-${heroResult.media_type as "movie" | "tv"}`
],
tmdbId: heroResult.id as number,
type: heroResult.media_type as "movie" | "tv",
title:
@@ -111,9 +127,13 @@ export const popular = os.explore.popular
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: (r.vote_average as number | undefined) ?? null,
}));
const titleIdsByLookup = getBrowseTitleIds(
baseItems.map((item) => ({ tmdbId: item.tmdbId, type: item.type })),
);
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
const items = baseItems.map((item) => ({
...item,
id: titleIdsByLookup[browseLookupKey(item)],
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
+8
View File
@@ -451,6 +451,10 @@ export const PersonCreditSchema = z
/** Reusable TMDB browse result (trending / popular / discover items) */
export const TmdbBrowseItem = z
.object({
id: z
.string()
.optional()
.describe("Internal title ID when the title already exists locally"),
tmdbId: z.number().describe("TMDB numeric ID"),
type: mediaType,
title: z.string().describe("Display title"),
@@ -690,6 +694,10 @@ export const TrendingOutput = z
items: z.array(TmdbBrowseItem).describe("Trending titles"),
hero: z
.object({
id: z
.string()
.optional()
.describe("Internal title ID when the title already exists locally"),
tmdbId: z.number().describe("TMDB numeric ID"),
type: mediaType,
title: z.string().describe("Display title"),