mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,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" />;
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user