diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 0e4a32a5..3f96282e 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -9,7 +9,7 @@ import React, { useMemo, useState, } from "react"; -import { FlatList, View } from "react-native"; +import { FlatList, RefreshControl, View } from "react-native"; import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; @@ -38,6 +38,7 @@ import { getUserLibraryApi, } from "@jellyfin/sdk/lib/utils/api"; import { FlashList } from "@shopify/flash-list"; +import { Loader } from "@/components/Loader"; const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter); @@ -90,7 +91,7 @@ const Page = () => { }; }, []); - const { data: library } = useQuery({ + const { data: library, isLoading: isLibraryLoading } = useQuery({ queryKey: ["library", libraryId], queryFn: async () => { if (!api) return null; @@ -101,7 +102,7 @@ const Page = () => { return response.data; }, enabled: !!api && !!user?.Id && !!libraryId, - staleTime: 0, + staleTime: 60 * 1000, }); const fetchItems = useCallback( @@ -112,28 +113,6 @@ const Page = () => { }): Promise => { if (!api || !library) return null; - let includeItemTypes: BaseItemKind[] | undefined = []; - - console.log("Page:", pageParam); - - // 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 = ["Series", "Movie", "CollectionFolder"]; - // break; - // } - const response = await getItemsApi(api).getItems({ userId: user?.Id, parentId: libraryId, @@ -141,7 +120,6 @@ const Page = () => { startIndex: pageParam, sortBy: [sortBy[0].key, "SortName", "ProductionYear"], sortOrder: [sortOrder[0].key], - includeItemTypes, enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], recursive: false, imageTypeLimit: 1, @@ -166,40 +144,41 @@ const Page = () => { ] ); - 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 { data, isFetching, fetchNextPage, hasNextPage, isLoading } = + 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 - ); + 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, - }); + if (accumulatedItems < totalItems) { + return lastPage?.Items?.length * pages.length; + } else { + return undefined; + } + }, + initialPageParam: 0, + enabled: !!api && !!user?.Id && !!library, + }); const flatData = useMemo(() => { return ( @@ -396,7 +375,19 @@ const Page = () => { ] ); - if (!library) return null; + if (isLoading || isLibraryLoading) + return ( + + + + ); + + if (flatData.length === 0) + return ( + + No items found + + ); return ( ( + + + + + + Display + + + + Display + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + display: "row", + }, + }) + } + > + + + Row + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + display: "list", + }, + }) + } + > + + + List + + + + + + + Image style + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + imageStyle: "poster", + }, + }) + } + > + + + Poster + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + imageStyle: "cover", + }, + }) + } + > + + + Cover + + + + + + + { + if (settings.libraryOptions.imageStyle === "poster") + return; + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + showTitles: newValue === "on" ? true : false, + }, + }); + }} + > + + + Show titles + + + + + + + + ), }} /> { + for (const item of data || []) { + queryClient.prefetchQuery({ + queryKey: ["library", item.Id], + queryFn: async () => { + if (!item.Id || !user?.Id || !api) return null; + const response = await getUserLibraryApi(api).getItem({ + itemId: item.Id, + userId: user?.Id, + }); + return response.data; + }, + staleTime: 60 * 1000, + }); + } + }, [data]); + if (isLoading) return ( @@ -41,59 +61,38 @@ export default function index() { ); + if (!data) + return ( + + No libraries found + + ); + return ( } keyExtractor={(item) => item.Id || ""} - ItemSeparatorComponent={() => } + ItemSeparatorComponent={() => + settings?.libraryOptions?.display === "row" ? ( + + ) : ( + + ) + } estimatedItemSize={200} /> ); } - -interface Props { - library: BaseItemDto; -} - -const LibraryItemCard: React.FC = ({ library }) => { - const [api] = useAtom(apiAtom); - - const url = useMemo( - () => - getPrimaryImageUrl({ - api, - item: library, - }), - [library] - ); - - if (!url) return null; - - return ( - - - - - {library.Name} - - - - ); -}; diff --git a/bun.lockb b/bun.lockb index 6eea000f..32f4452c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx new file mode 100644 index 00000000..0bf299bb --- /dev/null +++ b/components/library/LibraryItemCard.tsx @@ -0,0 +1,179 @@ +import { TouchableOpacityProps, View, ViewProps } from "react-native"; +import { Text } from "@/components/common/Text"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { useAtom } from "jotai"; +import { useEffect, useMemo, useState } from "react"; +import { TouchableItemRouter } from "../common/TouchableItemRouter"; +import { + BaseItemDto, + BaseItemKind, + CollectionType, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { getColors, ImageColorsResult } from "react-native-image-colors"; +import { useQuery } from "@tanstack/react-query"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { sortBy } from "lodash"; +import { useSettings } from "@/utils/atoms/settings"; +import { Ionicons } from "@expo/vector-icons"; + +interface Props extends TouchableOpacityProps { + library: BaseItemDto; +} + +type LibraryColor = { + dominantColor: string; + averageColor: string; + secondary: string; +}; + +type IconName = React.ComponentProps["name"]; + +const icons: Record = { + movies: "film", + tvshows: "tv", + music: "musical-notes", + books: "book", + homevideos: "videocam", + boxsets: "albums", + playlists: "list", + folders: "folder", + livetv: "tv", + musicvideos: "musical-notes", + photos: "images", + trailers: "videocam", + unknown: "help-circle", +} as const; +export const LibraryItemCard: React.FC = ({ library, ...props }) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const [settings] = useSettings(); + + const [imageInfo, setImageInfo] = useState({ + dominantColor: "#fff", + averageColor: "#fff", + secondary: "#fff", + }); + + const url = useMemo( + () => + getPrimaryImageUrl({ + api, + item: library, + }), + [library] + ); + + const { data: itemsCount } = useQuery({ + queryKey: ["library-count", library.Id], + queryFn: async () => { + if (!api) return null; + const response = await getItemsApi(api).getItems({ + userId: user?.Id, + parentId: library.Id, + limit: 0, + }); + return response.data.TotalRecordCount; + }, + }); + + useEffect(() => { + if (url) { + getColors(url, { + fallback: "#fff", + cache: true, + key: url, + }) + .then((colors) => { + let dominantColor: string = "#fff"; + let averageColor: string = "#fff"; + let secondary: string = "#fff"; + + if (colors.platform === "android") { + dominantColor = colors.dominant; + averageColor = colors.average; + secondary = colors.muted; + } else if (colors.platform === "ios") { + dominantColor = colors.primary; + averageColor = colors.background; + secondary = colors.detail; + } + + setImageInfo({ + dominantColor, + averageColor, + secondary, + }); + }) + .catch((error) => { + console.log("Error getting colors", error); + }); + } + }, [url]); + + if (!url) return null; + + if (settings?.libraryOptions?.display === "row") { + return ( + + + + + {library.Name} + + + + ); + } + + if (settings?.libraryOptions?.imageStyle === "cover") { + return ( + + + + {settings?.libraryOptions?.showTitles && ( + + {library.Name} + + )} + + + ); + } + + return ( + + + + + {library.Name} + + + {itemsCount} items + + + + + + + + ); +}; diff --git a/package.json b/package.json index 7382feb7..eb62667e 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "react-native-gesture-handler": "~2.16.1", "react-native-get-random-values": "^1.11.0", "react-native-google-cast": "^4.8.2", + "react-native-image-colors": "^2.4.0", "react-native-ios-context-menu": "^2.5.1", "react-native-ios-utilities": "^4.4.5", "react-native-reanimated": "~3.10.1", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 4d38d60f..402b011c 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -24,6 +24,13 @@ export const DownloadOptions: DownloadOption[] = [ }, ]; +export type LibraryOptions = { + display: "row" | "list"; + cardStyle: "compact" | "detailed"; + imageStyle: "poster" | "cover"; + showTitles: boolean; +}; + type Settings = { autoRotate?: boolean; forceLandscapeInVideoPlayer?: boolean; @@ -36,6 +43,7 @@ type Settings = { marlinServerUrl?: string; openInVLC?: boolean; downloadQuality?: DownloadOption; + libraryOptions: LibraryOptions; }; /** @@ -59,6 +67,12 @@ const loadSettings = async (): Promise => { marlinServerUrl: "", openInVLC: false, downloadQuality: DownloadOptions[0], + libraryOptions: { + display: "list", + cardStyle: "detailed", + imageStyle: "cover", + showTitles: true, + }, }; try {