feat: major refactor of navigation

This commit is contained in:
Fredrik Burmester
2024-08-23 16:41:59 +02:00
parent d330dd8db4
commit 725ba1ccaf
27 changed files with 225 additions and 329 deletions

View File

@@ -0,0 +1,434 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import { FlatList, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
sortByAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
getItemsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
const Page = () => {
const searchParams = useLocalSearchParams();
const { libraryId } = searchParams as { libraryId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
);
useLayoutEffect(() => {
setSortBy([
{
key: "SortName",
value: "Name",
},
]);
setSortOrder([
{
key: "Ascending",
value: "Ascending",
},
]);
}, []);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
}
);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
const { data: library } = useQuery({
queryKey: ["library", libraryId],
queryFn: async () => {
if (!api) return null;
const response = await getUserLibraryApi(api).getItem({
itemId: libraryId,
userId: user?.Id,
});
return response.data;
},
enabled: !!api && !!user?.Id && !!libraryId,
staleTime: 0,
});
const fetchItems = useCallback(
async ({
pageParam,
}: {
pageParam: number;
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !library) return null;
let includeItemTypes: BaseItemKind[] | undefined = [];
switch (library?.CollectionType) {
case "movies":
includeItemTypes.push("Movie");
break;
case "boxsets":
includeItemTypes.push("BoxSet");
break;
case "tvshows":
includeItemTypes.push("Series");
break;
case "music":
includeItemTypes.push("MusicAlbum");
break;
default:
includeItemTypes = undefined;
break;
}
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: libraryId,
limit: 20,
startIndex: pageParam,
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
sortOrder: [sortOrder[0].key],
includeItemTypes,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
recursive: true,
imageTypeLimit: 1,
fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
});
return response.data || null;
},
[
api,
user?.Id,
libraryId,
library,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]
);
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: [
"library-items",
libraryId,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
lastPage?.TotalRecordCount === 0
)
return undefined;
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
} else {
return undefined;
}
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!library,
});
const flatData = useMemo(() => {
return (
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
[]
);
}, [data]);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<MemoizedTouchableItemRouter
key={item.Id}
style={{
width: "100%",
marginBottom:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
}}
item={item}
>
<View
style={{
alignSelf:
index % 3 === 0
? "flex-end"
: (index + 1) % 3 === 0
? "flex-start"
: "center",
width: "89%",
}}
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</MemoizedTouchableItemRouter>
),
[orientation]
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
<View className="">
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
display: "flex",
paddingHorizontal: 15,
paddingVertical: 16,
flexDirection: "row",
}}
data={[
{
key: "reset",
component: <ResetFiltersButton />,
},
{
key: "genre",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="genreFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
values={selectedGenres}
title="Genres"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "year",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="yearFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Years || [];
}}
set={setSelectedYears}
values={selectedYears}
title="Years"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
),
},
{
key: "tags",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="tagsFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Tags || [];
}}
set={setSelectedTags}
values={selectedTags}
title="Tags"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortBy",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="sortBy"
queryFn={async () => sortOptions}
set={setSortBy}
values={sortBy}
title="Sort By"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortOrder",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="sortOrder"
queryFn={async () => sortOrderOptions}
set={setSortOrder}
values={sortOrder}
title="Sort Order"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
/>
</View>
),
[
libraryId,
api,
user?.Id,
selectedGenres,
setSelectedGenres,
selectedYears,
setSelectedYears,
selectedTags,
setSelectedTags,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
isFetching,
]
);
if (!library) return null;
return (
<FlashList
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text>
</View>
}
contentInsetAdjustmentBehavior="automatic"
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
estimatedItemSize={255}
numColumns={
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
></View>
)}
/>
);
};
export default React.memo(Page);