import { Feather, Ionicons } from "@expo/vector-icons"; import type { BaseItemDto, BaseItemDtoQueryResult, BaseItemKind, } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi, getSuggestionsApi, getTvShowsApi, getUserLibraryApi, getUserViewsApi, } from "@jellyfin/sdk/lib/utils/api"; import { type QueryFunction, useQuery } from "@tanstack/react-query"; import { useNavigation, useSegments } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, Platform, RefreshControl, ScrollView, View, } from "react-native"; import { Pressable } from "react-native-gesture-handler"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList"; import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists"; import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations"; import { Loader } from "@/components/Loader"; import { MediaListSection } from "@/components/medialists/MediaListSection"; import { Colors } from "@/constants/Colors"; import useRouter from "@/hooks/useAppRouter"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useDownload } from "@/providers/DownloadProvider"; import { useIntroSheet } from "@/providers/IntroSheetProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; import { storage } from "@/utils/mmkv"; type InfiniteScrollingCollectionListSection = { type: "InfiniteScrollingCollectionList"; title?: string; queryKey: (string | undefined | null)[]; queryFn: QueryFunction; orientation?: "horizontal" | "vertical"; pageSize?: number; priority?: 1 | 2; // 1 = high priority (loads first), 2 = low priority parentId?: string; // Library ID for "See All" navigation }; type MediaListSectionType = { type: "MediaListSection"; queryKey: (string | undefined)[]; queryFn: QueryFunction; priority?: 1 | 2; }; type Section = InfiniteScrollingCollectionListSection | MediaListSectionType; export const Home = () => { const router = useRouter(); const { t } = useTranslation(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const insets = useSafeAreaInsets(); const [loading, setLoading] = useState(false); const { settings, refreshStreamyfinPluginSettings } = useSettings(); const navigation = useNavigation(); const scrollRef = useRef(null); const { downloadedItems, cleanCacheDirectory } = useDownload(); const prevIsConnected = useRef(false); const { isConnected, serverConnected, loading: retryLoading, retryCheck, } = useNetworkStatus(); const invalidateCache = useInvalidatePlaybackProgressCache(); const [loadedSections, setLoadedSections] = useState>(new Set()); const { showIntro } = useIntroSheet(); // Show intro modal on first launch useEffect(() => { const hasShownIntro = storage.getBoolean("hasShownIntro"); if (!hasShownIntro) { const timer = setTimeout(() => { showIntro(); }, 1000); return () => { clearTimeout(timer); }; } }, [showIntro]); useEffect(() => { if (isConnected && !prevIsConnected.current) { invalidateCache(); } prevIsConnected.current = isConnected; }, [isConnected, invalidateCache]); const hasDownloads = useMemo(() => { if (Platform.isTV) return false; return downloadedItems.length > 0; }, [downloadedItems]); useEffect(() => { if (Platform.isTV) { navigation.setOptions({ headerLeft: () => null, }); return; } navigation.setOptions({ headerLeft: () => ( { router.push("/(auth)/downloads"); }} className='ml-1.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > ), }); }, [navigation, router, hasDownloads]); useEffect(() => { cleanCacheDirectory().catch((_e) => console.error("Something went wrong cleaning cache directory"), ); }, []); const segments = useSegments(); useEffect(() => { const unsubscribe = eventBus.on("scrollToTop", () => { if ((segments as string[])[2] === "(home)") scrollRef.current?.scrollTo({ y: Platform.isTV ? -152 : -100, animated: true, }); }); return () => { unsubscribe(); }; }, [segments]); const { data, isError: e1, isLoading: l1, } = useQuery({ queryKey: ["home", "userViews", user?.Id], queryFn: async () => { if (!api || !user?.Id) { return null; } const response = await getUserViewsApi(api).getUserViews({ userId: user.Id, }); return response.data.Items || null; }, enabled: !!api && !!user?.Id, staleTime: 60 * 1000, }); const userViews = useMemo( () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), [data, settings?.hiddenLibraries], ); const collections = useMemo(() => { const allow = ["movies", "tvshows"]; return ( userViews?.filter( (c) => c.CollectionType && allow.includes(c.CollectionType), ) || [] ); }, [userViews]); const refetch = async () => { setLoading(true); setLoadedSections(new Set()); await refreshStreamyfinPluginSettings(); await invalidateCache(); setLoading(false); }; const createCollectionConfig = useCallback( ( title: string, queryKey: string[], includeItemTypes: BaseItemKind[], parentId: string | undefined, pageSize: number = 10, ): InfiniteScrollingCollectionListSection => ({ title, queryKey, queryFn: async ({ pageParam = 0 }) => { if (!api) return []; // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side const allData = ( await getUserLibraryApi(api).getLatestMedia({ userId: user?.Id, limit: 10, fields: ["PrimaryImageAspectRatio"], imageTypeLimit: 1, enableImageTypes: ["Primary", "Backdrop", "Thumb"], includeItemTypes, parentId, }) ).data || []; // Simulate pagination by slicing return allData.slice(pageParam, pageParam + pageSize); }, type: "InfiniteScrollingCollectionList", pageSize, parentId, }), [api, user?.Id], ); const defaultSections = useMemo(() => { if (!api || !user?.Id) return []; const latestMediaViews = collections.map((c) => { const includeItemTypes: BaseItemKind[] = c.CollectionType === "tvshows" || c.CollectionType === "movies" ? [] : ["Movie"]; const title = t("home.recently_added_in", { libraryName: c.Name }); const queryKey: string[] = [ "home", `recentlyAddedIn${c.CollectionType}`, user.Id!, c.Id!, ]; return createCollectionConfig( title || "", queryKey, includeItemTypes, c.Id, 10, ); }); // Helper to sort items by most recent activity const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => { return items.sort((a, b) => { const dateA = a.UserData?.LastPlayedDate || a.DateCreated || ""; const dateB = b.UserData?.LastPlayedDate || b.DateCreated || ""; return new Date(dateB).getTime() - new Date(dateA).getTime(); }); }; // Helper to deduplicate items by ID const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => { const seen = new Set(); return items.filter((item) => { if (!item.Id || seen.has(item.Id)) return false; seen.add(item.Id); return true; }); }; // Build the first sections based on merge setting const firstSections: Section[] = settings.mergeNextUpAndContinueWatching ? [ { title: t("home.continue_and_next_up"), queryKey: ["home", "continueAndNextUp"], queryFn: async ({ pageParam = 0 }) => { // Fetch both in parallel const [resumeResponse, nextUpResponse] = await Promise.all([ getItemsApi(api).getResumeItems({ userId: user.Id, enableImageTypes: ["Primary", "Backdrop", "Thumb"], includeItemTypes: ["Movie", "Series", "Episode"], startIndex: 0, limit: 20, }), getTvShowsApi(api).getNextUp({ userId: user?.Id, startIndex: 0, limit: 20, enableImageTypes: ["Primary", "Backdrop", "Thumb"], enableResumable: false, }), ]); const resumeItems = resumeResponse.data.Items || []; const nextUpItems = nextUpResponse.data.Items || []; // Combine, sort by recent activity, deduplicate const combined = [...resumeItems, ...nextUpItems]; const sorted = sortByRecentActivity(combined); const deduplicated = deduplicateById(sorted); // Paginate client-side return deduplicated.slice(pageParam, pageParam + 10); }, type: "InfiniteScrollingCollectionList", orientation: "horizontal", pageSize: 10, priority: 1, }, ] : [ { title: t("home.continue_watching"), queryKey: ["home", "resumeItems"], queryFn: async ({ pageParam = 0 }) => ( await getItemsApi(api).getResumeItems({ userId: user.Id, enableImageTypes: ["Primary", "Backdrop", "Thumb"], includeItemTypes: ["Movie", "Series", "Episode"], startIndex: pageParam, limit: 10, }) ).data.Items || [], type: "InfiniteScrollingCollectionList", orientation: "horizontal", pageSize: 10, priority: 1, }, { title: t("home.next_up"), queryKey: ["home", "nextUp-all"], queryFn: async ({ pageParam = 0 }) => ( await getTvShowsApi(api).getNextUp({ userId: user?.Id, startIndex: pageParam, limit: 10, enableImageTypes: ["Primary", "Backdrop", "Thumb"], enableResumable: false, }) ).data.Items || [], type: "InfiniteScrollingCollectionList", orientation: "horizontal", pageSize: 10, priority: 1, }, ]; const ss: Section[] = [ ...firstSections, ...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })), // Only show Jellyfin suggested movies if StreamyStats recommendations are disabled ...(!settings?.streamyStatsMovieRecommendations ? [ { title: t("home.suggested_movies"), queryKey: ["home", "suggestedMovies", user?.Id], queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => ( await getSuggestionsApi(api).getSuggestions({ userId: user?.Id, startIndex: pageParam, limit: 10, mediaType: ["Video"], type: ["Movie"], }) ).data.Items || [], type: "InfiniteScrollingCollectionList" as const, orientation: "vertical" as const, pageSize: 10, priority: 2 as const, }, ] : []), ]; return ss; }, [ api, user?.Id, collections, t, createCollectionConfig, settings?.streamyStatsMovieRecommendations, settings.mergeNextUpAndContinueWatching, ]); const customSections = useMemo(() => { if (!api || !user?.Id || !settings?.home?.sections) return []; const ss: Section[] = []; settings.home.sections.forEach((section, index) => { const id = section.title || `section-${index}`; const pageSize = 10; ss.push({ title: t(`${id}`), queryKey: ["home", "custom", String(index), section.title ?? null], queryFn: async ({ pageParam = 0 }) => { if (section.items) { const response = await getItemsApi(api).getItems({ userId: user?.Id, startIndex: pageParam, limit: section.items?.limit || pageSize, recursive: true, includeItemTypes: section.items?.includeItemTypes, sortBy: section.items?.sortBy, sortOrder: section.items?.sortOrder, filters: section.items?.filters, parentId: section.items?.parentId, }); return response.data.Items || []; } if (section.nextUp) { const response = await getTvShowsApi(api).getNextUp({ userId: user?.Id, startIndex: pageParam, limit: section.nextUp?.limit || pageSize, enableImageTypes: ["Primary", "Backdrop", "Thumb"], enableResumable: section.nextUp?.enableResumable, enableRewatching: section.nextUp?.enableRewatching, }); return response.data.Items || []; } if (section.latest) { // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side const allData = ( await getUserLibraryApi(api).getLatestMedia({ userId: user?.Id, includeItemTypes: section.latest?.includeItemTypes, limit: section.latest?.limit || 10, isPlayed: section.latest?.isPlayed, groupItems: section.latest?.groupItems, }) ).data || []; // Simulate pagination by slicing return allData.slice(pageParam, pageParam + pageSize); } if (section.custom) { const response = await api.get( section.custom.endpoint, { params: { ...(section.custom.query || {}), userId: user?.Id, startIndex: pageParam, limit: pageSize, }, headers: section.custom.headers || {}, }, ); return response.data.Items || []; } return []; }, type: "InfiniteScrollingCollectionList", orientation: section?.orientation || "vertical", pageSize, // First 2 custom sections are high priority priority: index < 2 ? 1 : 2, }); }); return ss; }, [api, user?.Id, settings?.home?.sections, t]); const sections = settings?.home?.sections ? customSections : defaultSections; // Get all high priority section keys and check if all have loaded const highPrioritySectionKeys = useMemo(() => { return sections .filter((s) => s.priority === 1) .map((s) => s.queryKey.join("-")); }, [sections]); const allHighPriorityLoaded = useMemo(() => { return highPrioritySectionKeys.every((key) => loadedSections.has(key)); }, [highPrioritySectionKeys, loadedSections]); const markSectionLoaded = useCallback( (queryKey: (string | undefined | null)[]) => { const key = queryKey.join("-"); setLoadedSections((prev) => new Set(prev).add(key)); }, [], ); if (!isConnected || serverConnected !== true) { let title = ""; let subtitle = ""; if (!isConnected) { title = t("home.no_internet"); subtitle = t("home.no_internet_message"); } else if (serverConnected === null) { title = t("home.checking_server_connection"); subtitle = t("home.checking_server_connection_message"); } else if (!serverConnected) { title = t("home.server_unreachable"); subtitle = t("home.server_unreachable_message"); } return ( {title} {subtitle} {!Platform.isTV && ( )} ); } if (e1) return ( {t("home.oops")} {t("home.error_message")} ); if (l1) return ( ); return ( } contentContainerStyle={{ paddingLeft: insets.left, paddingRight: insets.right, paddingBottom: 16, }} > {sections.map((section, index) => { // Render Streamystats sections after Continue Watching and Next Up // When merged, they appear after index 0; otherwise after index 1 const streamystatsIndex = settings.mergeNextUpAndContinueWatching ? 0 : 1; const hasStreamystatsContent = settings.streamyStatsMovieRecommendations || settings.streamyStatsSeriesRecommendations || settings.streamyStatsPromotedWatchlists; const streamystatsSections = index === streamystatsIndex && hasStreamystatsContent ? ( {settings.streamyStatsMovieRecommendations && ( )} {settings.streamyStatsSeriesRecommendations && ( )} {settings.streamyStatsPromotedWatchlists && ( )} ) : null; if (section.type === "InfiniteScrollingCollectionList") { const isHighPriority = section.priority === 1; const handleSeeAll = section.parentId ? () => { router.push({ pathname: "/(auth)/(tabs)/(libraries)/[libraryId]", params: { libraryId: section.parentId!, sortBy: SortByOption.DateCreated, sortOrder: SortOrderOption.Descending, }, } as any); } : undefined; return ( markSectionLoaded(section.queryKey) : undefined } onPressSeeAll={handleSeeAll} /> {streamystatsSections} ); } if (section.type === "MediaListSection") { return ( {streamystatsSections} ); } return null; })} ); };