feat(core): add pagination to explore, search, person, and library endpoints

Convert trending, search, people.detail, and dashboard.library to page-based responses (`page` / `totalPages`) so clients can load results incrementally.

Update `@sofa/core` discovery and person services to accept a `page` / `limit` input and slice results accordingly. Add a new DB migration to persist the data needed to back paginated filmography queries.

On the web, introduce a `useInfiniteScroll` hook and wire up `useInfiniteQuery` in the explore, person detail, and filterable title row components with an intersection-observer sentinel. On native, switch the same screens from `useQuery` to `useInfiniteQuery` with `onEndReached` / `ListFooterComponent` loading indicators. Also replace remaining `FlatList` usages with `FlashList` in the home and title detail screens.
This commit is contained in:
2026-03-15 17:37:39 -04:00
parent d0eca7cf32
commit 6c193a9271
34 changed files with 4135 additions and 313 deletions
+34 -8
View File
@@ -1,7 +1,7 @@
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Stack } from "expo-router";
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { RefreshControl, ScrollView, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
@@ -11,8 +11,13 @@ import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
export default function ExploreScreen() {
const trending = useQuery(
orpc.explore.trending.queryOptions({ input: { type: "all" } }),
const trending = useInfiniteQuery(
orpc.explore.trending.infiniteOptions({
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const popularMovies = useQuery(
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
@@ -37,7 +42,28 @@ export default function ExploreScreen() {
queryClient.invalidateQueries({ queryKey: orpc.discover.key() });
}, []);
const heroItem = trending.data?.hero;
const heroItem = trending.data?.pages[0]?.hero ?? null;
const trendingItems = useMemo(
() => trending.data?.pages.flatMap((p) => p.items) ?? [],
[trending.data?.pages],
);
const trendingStatuses = useMemo(
() =>
Object.assign(
{},
...(trending.data?.pages.map((p) => p.userStatuses) ?? []),
) as Record<string, "watchlist" | "in_progress" | "completed">,
[trending.data?.pages],
);
const trendingProgress = useMemo(
() =>
Object.assign(
{},
...(trending.data?.pages.map((p) => p.episodeProgress) ?? []),
) as Record<string, { watched: number; total: number }>,
[trending.data?.pages],
);
return (
<ScrollView
@@ -68,9 +94,9 @@ export default function ExploreScreen() {
title="Trending Today"
icon={IconFlame}
mediaType="movie"
defaultItems={trending.data?.items ?? []}
defaultUserStatuses={trending.data?.userStatuses ?? {}}
defaultEpisodeProgress={trending.data?.episodeProgress ?? {}}
defaultItems={trendingItems}
defaultUserStatuses={trendingStatuses}
defaultEpisodeProgress={trendingProgress}
isLoading={trending.isPending}
/>
</Animated.View>
+30 -12
View File
@@ -1,3 +1,4 @@
import { FlashList } from "@shopify/flash-list";
import {
IconBooks,
IconPlayerPlay,
@@ -6,7 +7,7 @@ import {
import { useQuery } from "@tanstack/react-query";
import { Stack, useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import { FlatList, RefreshControl, ScrollView, View } from "react-native";
import { RefreshControl, ScrollView, View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import { ContinueWatchingCard } from "@/components/dashboard/continue-watching-card";
@@ -18,6 +19,10 @@ import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
const horizontalListStyle = { overflow: "visible" as const };
const statsListContentStyle = { gap: 12, paddingHorizontal: 16 };
const continueWatchingContentStyle = { gap: 12, paddingHorizontal: 16 };
export default function DashboardScreen() {
const { push } = useRouter();
authClient.useSession();
@@ -26,7 +31,7 @@ export default function DashboardScreen() {
const continueWatching = useQuery(
orpc.dashboard.continueWatching.queryOptions(),
);
const library = useQuery(orpc.dashboard.library.queryOptions());
const library = useQuery(orpc.dashboard.library.queryOptions({ input: {} }));
const recommendations = useQuery(
orpc.dashboard.recommendations.queryOptions(),
);
@@ -52,6 +57,21 @@ export default function DashboardScreen() {
[stats.data],
);
const renderStatItem = useCallback(
({ item }: { item: (typeof statsData)[number] }) => (
<StatsCard label={item.label} value={item.value} />
),
[],
);
const renderContinueWatchingItem = useCallback(
({
item,
}: {
item: NonNullable<typeof continueWatching.data>["items"][number];
}) => <ContinueWatchingCard item={item} />,
[],
);
return (
<ScrollView
className="bg-background"
@@ -72,16 +92,14 @@ export default function DashboardScreen() {
<View className="gap-8">
{/* Stats */}
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<FlatList
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
data={statsData}
keyExtractor={(item) => item.label}
renderItem={({ item }) => (
<StatsCard label={item.label} value={item.value} />
)}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
renderItem={renderStatItem}
contentContainerStyle={statsListContentStyle}
style={horizontalListStyle}
/>
</Animated.View>
@@ -91,14 +109,14 @@ export default function DashboardScreen() {
<View className="px-4">
<SectionHeader title="Continue Watching" icon={IconPlayerPlay} />
</View>
<FlatList
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
data={continueWatching.data?.items ?? []}
keyExtractor={(item) => item.title.id}
renderItem={({ item }) => <ContinueWatchingCard item={item} />}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
renderItem={renderContinueWatchingItem}
contentContainerStyle={continueWatchingContentStyle}
style={horizontalListStyle}
/>
</Animated.View>
)}
+43 -14
View File
@@ -1,8 +1,12 @@
import { FlashList } from "@shopify/flash-list";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
skipToken,
useInfiniteQuery,
useMutation,
} from "@tanstack/react-query";
import { Stack, useRouter } from "expo-router";
import { useCallback, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { ActivityIndicator, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
import { RecentlyViewedList } from "@/components/search/recently-viewed-list";
import {
@@ -22,9 +26,16 @@ export default function SearchScreen() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query.trim(), 300);
const searchResults = useQuery({
...orpc.search.queryOptions({ input: { query: debouncedQuery } }),
enabled: debouncedQuery.length > 0,
const searchResults = useInfiniteQuery({
...orpc.search.infiniteOptions({
input:
debouncedQuery.length > 0
? (pageParam: number) => ({ query: debouncedQuery, page: pageParam })
: skipToken,
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
});
// Track which item is currently being resolved/added
@@ -98,15 +109,17 @@ export default function SearchScreen() {
// Memoize mapped results to maintain stable references
const allResults = useMemo<SearchResultItem[]>(
() =>
searchResults.data?.results?.map((r) => ({
tmdbId: r.tmdbId,
title: r.title,
type: r.type,
posterPath: r.posterPath,
profilePath: r.profilePath,
releaseDate: r.releaseDate,
})) ?? [],
[searchResults.data?.results],
searchResults.data?.pages.flatMap((page) =>
page.results.map((r) => ({
tmdbId: r.tmdbId,
title: r.title,
type: r.type,
posterPath: r.posterPath,
profilePath: r.profilePath,
releaseDate: r.releaseDate,
})),
) ?? [],
[searchResults.data?.pages],
);
const renderItem = useCallback(
@@ -161,6 +174,22 @@ export default function SearchScreen() {
renderItem={renderItem}
keyboardShouldPersistTaps="handled"
contentInsetAdjustmentBehavior="automatic"
onEndReached={() => {
if (
searchResults.hasNextPage &&
!searchResults.isFetchingNextPage
) {
searchResults.fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={
searchResults.isFetchingNextPage ? (
<View className="items-center py-4">
<ActivityIndicator />
</View>
) : null
}
/>
)}
</View>
+50 -9
View File
@@ -6,11 +6,16 @@ import {
IconMovie,
IconUser,
} from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { useInfiniteQuery } from "@tanstack/react-query";
import { format, parseISO } from "date-fns";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect } from "react";
import { Pressable, useWindowDimensions, View } from "react-native";
import { useCallback, useEffect, useMemo } from "react";
import {
ActivityIndicator,
Pressable,
useWindowDimensions,
View,
} from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind";
@@ -59,12 +64,35 @@ export default function PersonDetailScreen() {
const { handlePress, handleQuickAdd, addingKey } = usePosterActions();
const { data, isPending, isError } = useQuery(
orpc.people.detail.queryOptions({ input: { id } }),
const {
data,
isPending,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery(
orpc.people.detail.infiniteOptions({
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const person = data?.person;
const filmography = data?.filmography ?? [];
const person = data?.pages[0]?.person;
const filmography = useMemo(
() => data?.pages.flatMap((p) => p.filmography) ?? [],
[data?.pages],
);
const userStatuses = useMemo(
() =>
Object.assign(
{},
...(data?.pages.map((p) => p.userStatuses) ?? []),
) as Record<string, "watchlist" | "in_progress" | "completed">,
[data?.pages],
);
const personName = person?.name;
const personProfilePath = person?.profilePath ?? null;
@@ -94,7 +122,7 @@ export default function PersonDetailScreen() {
posterThumbHash={credit.posterThumbHash}
releaseDate={credit.releaseDate ?? credit.firstAirDate}
voteAverage={credit.voteAverage}
userStatus={data?.userStatuses?.[credit.titleId] ?? null}
userStatus={userStatuses[credit.titleId] ?? null}
width={undefined}
onPress={handlePress}
onQuickAdd={handleQuickAdd}
@@ -102,7 +130,7 @@ export default function PersonDetailScreen() {
/>
</View>
),
[columnWidth, data?.userStatuses, handlePress, handleQuickAdd, addingKey],
[columnWidth, userStatuses, handlePress, handleQuickAdd, addingKey],
);
if (isPending) {
@@ -285,6 +313,19 @@ export default function PersonDetailScreen() {
paddingHorizontal: FILMOGRAPHY_PADDING,
}}
ListHeaderComponent={listHeader}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={
isFetchingNextPage ? (
<View className="items-center py-4">
<ActivityIndicator />
</View>
) : null
}
/>
</View>
</>
+14 -6
View File
@@ -1,3 +1,4 @@
import { FlashList } from "@shopify/flash-list";
import {
IconBrandAppstore,
IconBrandGooglePlay,
@@ -14,8 +15,8 @@ import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { LinearGradient } from "expo-linear-gradient";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import * as WebBrowser from "expo-web-browser";
import { useEffect, useMemo, useRef } from "react";
import { FlatList, Platform, Pressable, ScrollView, View } from "react-native";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Platform, Pressable, ScrollView, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind";
@@ -40,6 +41,9 @@ import { queryClient } from "@/lib/query-client";
import { addRecentlyViewed } from "@/lib/recently-viewed";
import { toast } from "@/lib/toast";
const castListContentStyle = { gap: 12, paddingHorizontal: 16 };
const castListStyle = { overflow: "visible" as const };
export default function TitleDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets();
@@ -175,6 +179,10 @@ export default function TitleDetailScreen() {
})),
[recommendations.data],
);
const renderCastItem = useCallback(
({ item }: { item: (typeof cast)[number] }) => <CastCard person={item} />,
[],
);
const hydratedTitleId = useRef<string | null>(null);
useEffect(() => {
@@ -597,14 +605,14 @@ export default function TitleDetailScreen() {
iconColor={titleAccent}
/>
</View>
<FlatList
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
data={cast}
keyExtractor={(item, index) => `${item.id}-${index}`}
renderItem={({ item }) => <CastCard person={item} />}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
renderItem={renderCastItem}
contentContainerStyle={castListContentStyle}
style={castListStyle}
/>
</Animated.View>
)}
@@ -1,4 +1,5 @@
import { FlatList } from "react-native";
import { FlashList } from "@shopify/flash-list";
import { useCallback } from "react";
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
import { usePosterActions } from "@/hooks/use-poster-actions";
@@ -28,10 +29,34 @@ export function HorizontalPosterRow({
isLoading?: boolean;
}) {
const { handlePress, handleQuickAdd, addingKey } = usePosterActions();
const keyExtractor = useCallback(
(item: PosterRowItem) => item.id ?? `${item.tmdbId}-${item.type}`,
[],
);
const renderItem = useCallback(
({ item }: { item: PosterRowItem }) => (
<PosterCard
id={item.id}
tmdbId={item.tmdbId}
title={item.title}
type={item.type as "movie" | "tv"}
posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={item.userStatus}
episodeProgress={item.episodeProgress}
onPress={handlePress}
onQuickAdd={handleQuickAdd}
isAdding={addingKey === `${item.tmdbId}-${item.type}`}
/>
),
[addingKey, handlePress, handleQuickAdd],
);
if (isLoading) {
return (
<FlatList
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
data={[1, 2, 3, 4]}
@@ -44,28 +69,12 @@ export function HorizontalPosterRow({
}
return (
<FlatList
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
data={items}
keyExtractor={(item) => item.id ?? `${item.tmdbId}-${item.type}`}
renderItem={({ item }) => (
<PosterCard
id={item.id}
tmdbId={item.tmdbId}
title={item.title}
type={item.type as "movie" | "tv"}
posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={item.userStatus}
episodeProgress={item.episodeProgress}
onPress={handlePress}
onQuickAdd={handleQuickAdd}
isAdding={addingKey === `${item.tmdbId}-${item.type}`}
/>
)}
keyExtractor={keyExtractor}
renderItem={renderItem}
contentContainerStyle={listContentStyle}
style={listStyle}
/>
@@ -1,5 +1,5 @@
import type { Icon } from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { ScrollView, View } from "react-native";
import {
@@ -42,23 +42,48 @@ export function FilterableTitleRow({
}) {
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
const discover = useQuery({
...orpc.discover.queryOptions({
input: { type: mediaType, genreId: selectedGenre ?? 0 },
const discover = useInfiniteQuery({
...orpc.discover.infiniteOptions({
input:
selectedGenre != null
? (pageParam: number) => ({
type: mediaType,
genreId: selectedGenre,
page: pageParam,
})
: skipToken,
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
enabled: selectedGenre !== null,
});
const rawItems =
selectedGenre === null ? defaultItems : (discover.data?.items ?? []);
const discoverItems = useMemo(
() => discover.data?.pages.flatMap((p) => p.items) ?? [],
[discover.data?.pages],
);
const discoverStatuses = useMemo(
() =>
Object.assign(
{},
...(discover.data?.pages.map((p) => p.userStatuses) ?? []),
) as Record<string, TitleStatus>,
[discover.data?.pages],
);
const discoverProgress = useMemo(
() =>
Object.assign(
{},
...(discover.data?.pages.map((p) => p.episodeProgress) ?? []),
) as Record<string, { watched: number; total: number }>,
[discover.data?.pages],
);
const rawItems = selectedGenre === null ? defaultItems : discoverItems;
const userStatuses =
selectedGenre === null
? defaultUserStatuses
: (discover.data?.userStatuses ?? {});
selectedGenre === null ? defaultUserStatuses : discoverStatuses;
const episodeProgress =
selectedGenre === null
? defaultEpisodeProgress
: (discover.data?.episodeProgress ?? {});
selectedGenre === null ? defaultEpisodeProgress : discoverProgress;
const showLoading =
isLoading || (selectedGenre !== null && discover.isPending);