diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 157e9709..9aecff51 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -1,53 +1,393 @@ +import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; +import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel"; +import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; +import { Loader } from "@/components/Loader"; +import { MediaListSection } from "@/components/medialists/MediaListSection"; +import { Colors } from "@/constants/Colors"; import { TAB_HEIGHT } from "@/constants/Values"; -import { VlcPlayerView } from "@/modules/vlc-player"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { Feather, Ionicons } from "@expo/vector-icons"; +import { Api } from "@jellyfin/sdk"; import { - PlaybackStatePayload, - ProgressUpdatePayload, - TrackInfo, - VlcPlayerViewRef, -} from "@/modules/vlc-player/src/VlcPlayer.types"; -import React, { useEffect, useRef, useState } from "react"; -import { Button, ScrollView, TouchableOpacity, View } from "react-native"; + BaseItemDto, + BaseItemKind, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { + getItemsApi, + getSuggestionsApi, + getTvShowsApi, + getUserLibraryApi, + getUserViewsApi, +} from "@jellyfin/sdk/lib/utils/api"; +import NetInfo from "@react-native-community/netinfo"; +import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigation, useRouter } from "expo-router"; +import { useAtom, useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + ActivityIndicator, + Platform, + RefreshControl, + ScrollView, + TouchableOpacity, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +type ScrollingCollectionListSection = { + type: "ScrollingCollectionList"; + title?: string; + queryKey: (string | undefined | null)[]; + queryFn: QueryFunction; + orientation?: "horizontal" | "vertical"; +}; + +type MediaListSection = { + type: "MediaListSection"; + queryKey: (string | undefined)[]; + queryFn: QueryFunction; +}; + +type Section = ScrollingCollectionListSection | MediaListSection; + export default function index() { - const insets = useSafeAreaInsets(); - const videoRef = useRef(null); - const [playbackState, setPlaybackState] = useState< - PlaybackStatePayload["nativeEvent"] | null - >(null); - const [progress, setProgress] = useState< - ProgressUpdatePayload["nativeEvent"] | null - >(null); + const queryClient = useQueryClient(); + const router = useRouter(); + + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + + const [loading, setLoading] = useState(false); + const [settings, _] = useSettings(); + + const [isConnected, setIsConnected] = useState(null); + const [loadingRetry, setLoadingRetry] = useState(false); + + const { downloadedFiles } = useDownload(); + const navigation = useNavigation(); useEffect(() => { - videoRef.current?.play(); + const hasDownloads = downloadedFiles && downloadedFiles.length > 0; + navigation.setOptions({ + headerLeft: () => ( + { + router.push("/(auth)/downloads"); + }} + className="p-2" + > + + + ), + }); + }, [downloadedFiles, navigation, router]); + + const checkConnection = useCallback(async () => { + setLoadingRetry(true); + const state = await NetInfo.fetch(); + setIsConnected(state.isConnected); + setLoadingRetry(false); }, []); - const onProgress = (event: ProgressUpdatePayload) => { - const { currentTime, duration } = event.nativeEvent; - console.log(`Current Time: ${currentTime}, Duration: ${duration}`); - setProgress(event.nativeEvent); - }; - - const onPlaybackStateChanged = (event: PlaybackStatePayload) => { - const { isBuffering, currentTime, duration, target, type } = - event.nativeEvent; - console.log("onVideoStateChange", { - isBuffering, - currentTime, - duration, - target, - type, + useEffect(() => { + const unsubscribe = NetInfo.addEventListener((state) => { + if (state.isConnected == false || state.isInternetReachable === false) + setIsConnected(false); + else setIsConnected(true); }); - setPlaybackState(event.nativeEvent); - }; + + NetInfo.fetch().then((state) => { + setIsConnected(state.isConnected); + }); + + return () => { + unsubscribe(); + }; + }, []); + + const { + data: userViews, + 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 { + data: mediaListCollections, + isError: e2, + isLoading: l2, + } = useQuery({ + queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin], + queryFn: async () => { + if (!api || !user?.Id) return []; + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + tags: ["sf_promoted"], + recursive: true, + fields: ["Tags"], + includeItemTypes: ["BoxSet"], + }); + + return response.data.Items || []; + }, + enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true, + staleTime: 60 * 1000, + }); + + const collections = useMemo(() => { + const allow = ["movies", "tvshows"]; + return ( + userViews?.filter( + (c) => c.CollectionType && allow.includes(c.CollectionType) + ) || [] + ); + }, [userViews]); + + const refetch = useCallback(async () => { + setLoading(true); + await queryClient.invalidateQueries({ + queryKey: ["home"], + refetchType: "all", + type: "all", + exact: false, + }); + await queryClient.invalidateQueries({ + queryKey: ["home"], + refetchType: "all", + type: "all", + exact: false, + }); + await queryClient.invalidateQueries({ + queryKey: ["item"], + refetchType: "all", + type: "all", + exact: false, + }); + setLoading(false); + }, [queryClient]); + + const createCollectionConfig = useCallback( + ( + title: string, + queryKey: string[], + includeItemTypes: BaseItemKind[], + parentId: string | undefined + ): ScrollingCollectionListSection => ({ + title, + queryKey, + queryFn: async () => { + if (!api) return []; + return ( + ( + await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + limit: 50, + fields: ["PrimaryImageAspectRatio", "Path"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes, + parentId, + }) + ).data || [] + ); + }, + type: "ScrollingCollectionList", + }), + [api, user?.Id] + ); + + const sections = useMemo(() => { + if (!api || !user?.Id) return []; + + const latestMediaViews = collections.map((c) => { + const includeItemTypes: BaseItemKind[] = + c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; + const title = "Recently Added in " + c.Name; + const queryKey = [ + "home", + "recentlyAddedIn" + c.CollectionType, + user?.Id!, + c.Id!, + ]; + return createCollectionConfig( + title || "", + queryKey, + includeItemTypes, + c.Id + ); + }); + + const ss: Section[] = [ + { + title: "Continue Watching", + queryKey: ["home", "resumeItems", user.Id], + queryFn: async () => + ( + await getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + { + title: "Next Up", + queryKey: ["home", "nextUp-all", user?.Id], + queryFn: async () => + ( + await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + fields: ["MediaSourceCount"], + limit: 20, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + ...latestMediaViews, + ...(mediaListCollections?.map( + (ml) => + ({ + title: ml.Name, + queryKey: ["home", "mediaList", ml.Id!], + queryFn: async () => ml, + type: "MediaListSection", + orientation: "vertical", + } as Section) + ) || []), + { + title: "Suggested Movies", + queryKey: ["home", "suggestedMovies", user?.Id], + queryFn: async () => + ( + await getSuggestionsApi(api).getSuggestions({ + userId: user?.Id, + limit: 10, + mediaType: ["Video"], + type: ["Movie"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "vertical", + }, + { + title: "Suggested Episodes", + queryKey: ["home", "suggestedEpisodes", user?.Id], + queryFn: async () => { + try { + const suggestions = await getSuggestions(api, user.Id); + const nextUpPromises = suggestions.map((series) => + getNextUp(api, user.Id, series.Id) + ); + const nextUpResults = await Promise.all(nextUpPromises); + + return nextUpResults.filter((item) => item !== null) || []; + } catch (error) { + console.error("Error fetching data:", error); + return []; + } + }, + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + ]; + return ss; + }, [api, user?.Id, collections, mediaListCollections]); + + if (isConnected === false) { + return ( + + No Internet + + No worries, you can still watch{"\n"}downloaded content. + + + + + + + ); + } + + const insets = useSafeAreaInsets(); + + if (e1 || e2) + return ( + + Oops! + + Something went wrong.{"\n"}Please log out and in again. + + + ); + + if (l1 || l2) + return ( + + + + ); return ( + } key={"home"} contentContainerStyle={{ paddingLeft: insets.left, @@ -59,113 +399,58 @@ export default function index() { }} > - - + + + {sections.map((section, index) => { + if (section.type === "ScrollingCollectionList") { + return ( + + ); + } else if (section.type === "MediaListSection") { + return ( + + ); + } + return null; + })} -