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:
jake committed 2026-03-16 15:14:32 -04:00
1 parent e5fc18178b
commit ccc7922737
33 files changed
+367 -241

No files matched your search

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