diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index 85846a7b..333cc018 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -70,6 +70,19 @@ export default function TabLayout() { ), }} /> + ( + + ), + }} + /> ); } diff --git a/app/(auth)/(tabs)/library/_layout.tsx b/app/(auth)/(tabs)/library/_layout.tsx new file mode 100644 index 00000000..505bbe66 --- /dev/null +++ b/app/(auth)/(tabs)/library/_layout.tsx @@ -0,0 +1,30 @@ +import { Stack, useRouter } from "expo-router"; +import { Platform } from "react-native"; + +export default function IndexLayout() { + return ( + + + + + ); +} diff --git a/app/(auth)/(tabs)/library/collections/[collectionId].tsx b/app/(auth)/(tabs)/library/collections/[collectionId].tsx new file mode 100644 index 00000000..0f345649 --- /dev/null +++ b/app/(auth)/(tabs)/library/collections/[collectionId].tsx @@ -0,0 +1,305 @@ +import * as DropdownMenu from "zeego/dropdown-menu"; +import ArtistPoster from "@/components/ArtistPoster"; +import { ColumnItem } from "@/components/common/ColumnItem"; +import { Text } from "@/components/common/Text"; +import { ItemCardText } from "@/components/ItemCardText"; +import { Loading } from "@/components/Loading"; +import MoviePoster from "@/components/MoviePoster"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { Ionicons } from "@expo/vector-icons"; +import { + BaseItemDto, + BaseItemDtoQueryResult, + BaseItemKind, + ItemSortBy, + NameGuidPair, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getFilterApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { + QueryFilters, + useInfiniteQuery, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + Stack, + router, + useLocalSearchParams, + useNavigation, +} from "expo-router"; +import { useAtom } from "jotai"; +import React, { + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { + ActivityIndicator, + Platform, + RefreshControl, + ScrollView, + TouchableOpacity, + View, + ViewProps, +} from "react-native"; +import { + genreFilterAtom, + yearFilterAtom, + sortByAtom, + tagsFilterAtom, +} from "@/utils/atoms/filters"; +import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; +import { FilterButton } from "@/components/filters/FilterButton"; +import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; + +const page: React.FC = () => { + const searchParams = useLocalSearchParams(); + const navigation = useNavigation(); + const { collectionId } = searchParams as { collectionId: string }; + + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); + const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); + const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); + const [sortBy, setSortBy] = useAtom(sortByAtom); + + const { data: collection } = useQuery({ + queryKey: ["collection", collectionId], + queryFn: async () => { + if (!api) return null; + const response = await getItemsApi(api).getItems({ + userId: user?.Id, + ids: [collectionId], + }); + const data = response.data.Items?.[0]; + return data; + }, + enabled: !!api && !!user?.Id && !!collectionId, + staleTime: 0, + }); + + const fetchItems = useCallback( + async ({ + pageParam, + }: { + pageParam: number; + }): Promise => { + if (!api || !collection) return null; + + const sortBy: ItemSortBy[] = []; + const includeItemTypes: BaseItemKind[] = []; + + switch (collection?.CollectionType) { + case "movies": + sortBy.push("SortName", "ProductionYear"); + break; + case "boxsets": + sortBy.push("IsFolder", "SortName"); + break; + default: + sortBy.push("SortName"); + break; + } + + switch (collection?.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: + break; + } + + const response = await getItemsApi(api).getItems({ + userId: user?.Id, + parentId: collectionId, + limit: 50, + startIndex: pageParam, + sortBy, + sortOrder: ["Ascending"], + 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, + collectionId, + collection?.CollectionType, + selectedGenres, + selectedYears, + selectedTags, + ], + ); + + const { + status, + data, + error, + isFetching, + isFetchingNextPage, + isFetchingPreviousPage, + fetchNextPage, + fetchPreviousPage, + hasPreviousPage, + } = useInfiniteQuery({ + queryKey: [ + "library-items", + collection, + selectedGenres, + selectedYears, + selectedTags, + sortBy, + ], + queryFn: fetchItems, + getNextPageParam: (lastPage, pages) => { + const totalItems = lastPage?.TotalRecordCount || 0; + if ((lastPage?.Items?.length || 0) < totalItems) { + return lastPage?.Items?.length; + } else { + return undefined; + } + }, + initialPageParam: 0, + enabled: !!api && !!user?.Id && !!collection, + }); + + const type = useMemo(() => { + return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null; + }, [data]); + + if (!collection || !collection.CollectionType) return null; + + return ( + <> + page?.Items) || []} + horizontal={false} + contentInsetAdjustmentBehavior="automatic" + contentContainerStyle={{ + paddingTop: 17, + paddingHorizontal: 10, + paddingBottom: 150, + }} + onEndReached={fetchNextPage} + onEndReachedThreshold={0.5} + renderItem={({ item, index }) => + item ? ( + + + + + + + ) : null + } + numColumns={3} + estimatedItemSize={200} + ListHeaderComponent={ + + + + { + if (!api) return null; + const response = await getFilterApi( + api, + ).getQueryFiltersLegacy({ + userId: user?.Id, + includeItemTypes: type ? [type] : [], + parentId: collectionId, + }); + return response.data.Genres || []; + }} + set={setSelectedGenres} + values={selectedGenres} + title="Genres" + /> + { + if (!api) return null; + const response = await getFilterApi( + api, + ).getQueryFiltersLegacy({ + userId: user?.Id, + includeItemTypes: type ? [type] : [], + parentId: collectionId, + }); + return response.data.Tags || []; + }} + set={setSelectedTags} + values={selectedTags} + title="Tags" + /> + { + if (!api) return null; + const response = await getFilterApi( + api, + ).getQueryFiltersLegacy({ + userId: user?.Id, + includeItemTypes: type ? [type] : [], + parentId: collectionId, + }); + return ( + response.data.Years?.sort((a, b) => b - a).map((y) => + y.toString(), + ) || [] + ); + }} + set={setSelectedYears} + values={selectedYears} + title="Years" + /> + + {!type && isFetching && ( + + )} + + } + /> + + ); +}; + +export default page; diff --git a/app/(auth)/(tabs)/library/index.tsx b/app/(auth)/(tabs)/library/index.tsx new file mode 100644 index 00000000..275b1b61 --- /dev/null +++ b/app/(auth)/(tabs)/library/index.tsx @@ -0,0 +1,119 @@ +import { Text } from "@/components/common/Text"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "expo-router"; +import { useAtom } from "jotai"; +import { useCallback, useMemo, useState } from "react"; +import { + ActivityIndicator, + RefreshControl, + ScrollView, + TouchableOpacity, + View, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useSettings } from "@/utils/atoms/settings"; +import { FlashList } from "@shopify/flash-list"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { Image } from "expo-image"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +export default function index() { + const router = useRouter(); + const queryClient = useQueryClient(); + + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const [loading, setLoading] = useState(false); + const [settings, _] = useSettings(); + + const { data, isLoading: isLoading } = useQuery({ + queryKey: ["collections", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) { + return []; + } + + const data = ( + await getItemsApi(api).getItems({ + userId: user.Id, + sortBy: ["SortName", "DateCreated"], + }) + ).data; + + return data.Items || []; + }, + enabled: !!api && !!user?.Id, + staleTime: 60 * 1000, + }); + + if (isLoading) + return ( + + + + ); + + return ( + } + keyExtractor={(item) => item.Id || ""} + ItemSeparatorComponent={() => } + estimatedItemSize={200} + /> + ); +} + +interface Props { + collection: BaseItemDto; +} + +const CollectionCard: React.FC = ({ collection }) => { + const router = useRouter(); + + const [api] = useAtom(apiAtom); + + const url = useMemo( + () => + getPrimaryImageUrl({ + api, + item: collection, + }), + [collection], + ); + + if (!url) return null; + + return ( + { + router.push(`/library/collections/${collection.Id}`); + }} + > + + + {collection.Name} + + + ); +}; diff --git a/components/filters/FilterButton.tsx b/components/filters/FilterButton.tsx new file mode 100644 index 00000000..7cb65c9a --- /dev/null +++ b/components/filters/FilterButton.tsx @@ -0,0 +1,85 @@ +import * as DropdownMenu from "zeego/dropdown-menu"; +import { Text } from "@/components/common/Text"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { Ionicons } from "@expo/vector-icons"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import { TouchableOpacity, View, ViewProps } from "react-native"; + +interface Props extends ViewProps { + collectionId: string; + queryFn: (params: any) => Promise; + queryKey: string; + set: (value: string[]) => void; + values: string[]; + title: string; +} + +export const FilterButton: React.FC = ({ + collectionId, + queryFn, + queryKey, + set, + values, + title, + ...props +}) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const { data: filters } = useQuery({ + queryKey: [queryKey, collectionId], + queryFn, + staleTime: 0, + }); + + return ( + + + + 0 ? "bg-purple-600" : "bg-neutral-900"} + `} + {...props} + > + {title} + + + + + + {filters?.map((g) => ( + { + if (next === "on") { + set([...values, g]); + } else { + set(values.filter((v) => v !== g)); + } + }} + key={g} + textValue={g} + > + + + ))} + + + ); +}; diff --git a/components/filters/ResetFiltersButton.tsx b/components/filters/ResetFiltersButton.tsx new file mode 100644 index 00000000..14717d54 --- /dev/null +++ b/components/filters/ResetFiltersButton.tsx @@ -0,0 +1,36 @@ +import { + genreFilterAtom, + tagsFilterAtom, + yearFilterAtom, +} from "@/utils/atoms/filters"; +import { Ionicons } from "@expo/vector-icons"; +import { useAtom } from "jotai"; +import { TouchableOpacity } from "react-native"; + +export const ResetFiltersButton: React.FC = ({ ...props }) => { + const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); + const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); + const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); + + if ( + selectedGenres.length === 0 && + selectedTags.length === 0 && + selectedYears.length === 0 + ) { + return null; + } + + return ( + { + setSelectedGenres([]); + setSelectedTags([]); + setSelectedYears([]); + }} + className="bg-purple-600 rounded-full w-8 h-8 flex items-center justify-center" + {...props} + > + + + ); +}; diff --git a/utils/atoms/filters.ts b/utils/atoms/filters.ts new file mode 100644 index 00000000..f02a7a11 --- /dev/null +++ b/utils/atoms/filters.ts @@ -0,0 +1,7 @@ +import { NameGuidPair } from "@jellyfin/sdk/lib/generated-client/models"; +import { atom, useAtom } from "jotai"; + +export const genreFilterAtom = atom([]); +export const tagsFilterAtom = atom([]); +export const yearFilterAtom = atom([]); +export const sortByAtom = atom("title");