import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto, CollectionType, } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi, getUserViewsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { BlurView } from "expo-blur"; import { Image } from "expo-image"; import { LinearGradient } from "expo-linear-gradient"; import { useAtom } from "jotai"; import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Animated, Easing, FlatList, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import useRouter from "@/hooks/useAppRouter"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; const HORIZONTAL_PADDING = 80; const CARD_HEIGHT = 220; const CARD_GAP = 24; const SCALE_PADDING = 20; 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; interface LibraryWithPreview extends BaseItemDto { previewItems?: BaseItemDto[]; itemCount?: number; } const TVLibraryRow: React.FC<{ library: LibraryWithPreview; isFirst: boolean; onPress: () => void; }> = ({ library, isFirst, onPress }) => { const [api] = useAtom(apiAtom); const { t } = useTranslation(); const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; const opacity = useRef(new Animated.Value(0.7)).current; const animateTo = (toScale: number, toOpacity: number) => { Animated.parallel([ Animated.timing(scale, { toValue: toScale, duration: 200, easing: Easing.out(Easing.quad), useNativeDriver: true, }), Animated.timing(opacity, { toValue: toOpacity, duration: 200, easing: Easing.out(Easing.quad), useNativeDriver: true, }), ]).start(); }; const backdropUrl = useMemo(() => { // Try to get backdrop from library or first preview item if (library.previewItems?.[0]) { return getBackdropUrl({ api, item: library.previewItems[0], width: 1920, }); } return getBackdropUrl({ api, item: library, width: 1920, }); }, [api, library]); const iconName = icons[library.CollectionType!] || "folder"; const itemTypeName = useMemo(() => { if (library.CollectionType === "movies") return t("library.item_types.movies"); if (library.CollectionType === "tvshows") return t("library.item_types.series"); if (library.CollectionType === "boxsets") return t("library.item_types.boxsets"); if (library.CollectionType === "music") return t("library.item_types.items"); return t("library.item_types.items"); }, [library.CollectionType, t]); return ( { setFocused(true); animateTo(1.02, 1); }} onBlur={() => { setFocused(false); animateTo(1, 0.7); }} hasTVPreferredFocus={isFirst} > {/* Background Image */} {backdropUrl && ( )} {/* Gradient Overlay */} {/* Content */} {/* Icon Container */} {/* Text Content */} {library.Name} {library.itemCount !== undefined && ( {library.itemCount} {itemTypeName} )} {/* Arrow Indicator */} ); }; export const TVLibraries: React.FC = () => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { settings } = useSettings(); const insets = useSafeAreaInsets(); const router = useRouter(); const { t } = useTranslation(); const { data: userViews, isLoading: viewsLoading } = useQuery({ queryKey: ["user-views", user?.Id], queryFn: async () => { const response = await getUserViewsApi(api!).getUserViews({ userId: user?.Id, }); return response.data.Items || []; }, staleTime: 60 * 1000, enabled: !!api && !!user?.Id, }); const libraries = useMemo( () => userViews ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)) .filter((l) => l.CollectionType !== "books") || [], [userViews, settings?.hiddenLibraries], ); // Fetch item counts and preview items for each library const { data: librariesWithData, isLoading: dataLoading } = useQuery({ queryKey: ["library-data", libraries.map((l) => l.Id).join(",")], queryFn: async () => { const results: LibraryWithPreview[] = await Promise.all( libraries.map(async (library) => { let itemType: string | undefined; if (library.CollectionType === "movies") itemType = "Movie"; else if (library.CollectionType === "tvshows") itemType = "Series"; else if (library.CollectionType === "boxsets") itemType = "BoxSet"; // Fetch count const countResponse = await getItemsApi(api!).getItems({ userId: user?.Id, parentId: library.Id, recursive: true, limit: 0, includeItemTypes: itemType ? [itemType as any] : undefined, }); // Fetch preview items with backdrops const previewResponse = await getItemsApi(api!).getItems({ userId: user?.Id, parentId: library.Id, recursive: true, limit: 1, sortBy: ["Random"], includeItemTypes: itemType ? [itemType as any] : undefined, imageTypes: ["Backdrop"], }); return { ...library, itemCount: countResponse.data.TotalRecordCount, previewItems: previewResponse.data.Items || [], }; }), ); return results; }, enabled: !!api && !!user?.Id && libraries.length > 0, staleTime: 60 * 1000, }); const handleLibraryPress = useCallback( (library: BaseItemDto) => { if (library.CollectionType === "music") { router.push({ pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`, params: { libraryId: library.Id! }, }); } else { router.push({ pathname: "/(auth)/(tabs)/(libraries)/[libraryId]", params: { libraryId: library.Id! }, }); } }, [router], ); const renderItem = useCallback( ({ item, index }: { item: LibraryWithPreview; index: number }) => ( handleLibraryPress(item)} /> ), [handleLibraryPress], ); const isLoading = viewsLoading || dataLoading; const displayLibraries = librariesWithData || libraries; if (isLoading && libraries.length === 0) { return ( ); } if (!displayLibraries || displayLibraries.length === 0) { return ( {t("library.no_libraries_found")} ); } return ( item.Id || ""} renderItem={renderItem} showsVerticalScrollIndicator={false} removeClippedSubviews={false} contentContainerStyle={{ paddingBottom: 40, paddingHorizontal: insets.left + HORIZONTAL_PADDING, paddingVertical: SCALE_PADDING, }} /> ); };