From cfbac538f84c3e22ceab443416fe4aa94963503a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 19 Feb 2025 10:49:18 +0100 Subject: [PATCH] chore: refactor for tv stuff --- app/(auth)/(tabs)/(home)/index.tsx | 499 +------------------- app/(auth)/(tabs)/(home)/settings.tsx | 15 +- app/login.tsx | 77 +-- bun.lock | 4 +- components/settings/DownloadSettings.tv.tsx | 5 + components/settings/SettingsIndex.tsx | 495 +++++++++++++++++++ components/settings/SettingsIndex.tv.tsx | 463 ++++++++++++++++++ package.json | 2 +- providers/DownloadProvider.tsx | 12 +- providers/DownloadProvider.tv.tsx | 107 +++++ tsconfig.json | 11 +- 11 files changed, 1135 insertions(+), 555 deletions(-) create mode 100644 components/settings/DownloadSettings.tv.tsx create mode 100644 components/settings/SettingsIndex.tsx create mode 100644 components/settings/SettingsIndex.tv.tsx create mode 100644 providers/DownloadProvider.tv.tsx diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 0a1d8fca..89d03d0c 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -1,498 +1,5 @@ -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 { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; -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 { - 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 } from "@tanstack/react-query"; -import { useNavigation, useRouter } from "expo-router"; -import { useAtomValue } from "jotai"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Platform } from "react-native"; -import { useTranslation } from "react-i18next"; -import { - ActivityIndicator, - RefreshControl, - ScrollView, - TouchableOpacity, - View, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - useSplashScreenLoading, - useSplashScreenVisible, -} from "@/providers/SplashScreenProvider"; +import { SettingsIndex } from "@/components/settings/SettingsIndex"; -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 router = useRouter(); - - const { t } = useTranslation(); - - const api = useAtomValue(apiAtom); - const user = useAtomValue(userAtom); - - const [loading, setLoading] = useState(false); - const [ - settings, - updateSettings, - pluginSettings, - setPluginSettings, - refreshStreamyfinPluginSettings, - ] = useSettings(); - - const [isConnected, setIsConnected] = useState(null); - const [loadingRetry, setLoadingRetry] = useState(false); - - const navigation = useNavigation(); - - const insets = useSafeAreaInsets(); - - if (!Platform.isTV) { - const { downloadedFiles, cleanCacheDirectory } = useDownload(); - useEffect(() => { - const hasDownloads = downloadedFiles && downloadedFiles.length > 0; - navigation.setOptions({ - headerLeft: () => ( - { - router.push("/(auth)/downloads"); - }} - className="p-2" - > - - - ), - }); - }, [downloadedFiles, navigation, router]); - - useEffect(() => { - cleanCacheDirectory().catch((e) => - console.error("Something went wrong cleaning cache directory") - ); - }, []); - } - - const checkConnection = useCallback(async () => { - setLoadingRetry(true); - const state = await NetInfo.fetch(); - setIsConnected(state.isConnected); - setLoadingRetry(false); - }, []); - - useEffect(() => { - const unsubscribe = NetInfo.addEventListener((state) => { - if (state.isConnected == false || state.isInternetReachable === false) - setIsConnected(false); - else setIsConnected(true); - }); - - NetInfo.fetch().then((state) => { - setIsConnected(state.isConnected); - }); - - // cleanCacheDirectory().catch((e) => - // console.error("Something went wrong cleaning cache directory") - // ); - - return () => { - unsubscribe(); - }; - }, []); - - 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, - }); - - // show splash screen until query loaded - useSplashScreenLoading(l1); - const splashScreenVisible = useSplashScreenVisible(); - - 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 invalidateCache = useInvalidatePlaybackProgressCache(); - - const refetch = useCallback(async () => { - setLoading(true); - await refreshStreamyfinPluginSettings(); - await invalidateCache(); - setLoading(false); - }, []); - - 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: 20, - fields: ["PrimaryImageAspectRatio", "Path"], - imageTypeLimit: 1, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - includeItemTypes, - parentId, - }) - ).data || [] - ); - }, - type: "ScrollingCollectionList", - }), - [api, user?.Id] - ); - - let sections: Section[] = []; - if (!settings?.home || !settings?.home?.sections) { - sections = useMemo(() => { - if (!api || !user?.Id) return []; - - const latestMediaViews = collections.map((c) => { - const includeItemTypes: BaseItemKind[] = - c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; - const title = t("home.recently_added_in", { libraryName: c.Name }); - const queryKey = [ - "home", - "recentlyAddedIn" + c.CollectionType, - user?.Id!, - c.Id!, - ]; - return createCollectionConfig( - title || "", - queryKey, - includeItemTypes, - c.Id - ); - }); - - const ss: Section[] = [ - { - title: t("home.continue_watching"), - queryKey: ["home", "resumeItems"], - queryFn: async () => - ( - await getItemsApi(api).getResumeItems({ - userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - includeItemTypes: ["Movie", "Series", "Episode"], - }) - ).data.Items || [], - type: "ScrollingCollectionList", - orientation: "horizontal", - }, - { - title: t("home.next_up"), - queryKey: ["home", "nextUp-all"], - 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: t("home.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: t("home.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]); - } else { - sections = useMemo(() => { - if (!api || !user?.Id) return []; - const ss: Section[] = []; - - for (const key in settings.home?.sections) { - // @ts-expect-error - const section = settings.home?.sections[key]; - const id = section.title || key; - ss.push({ - title: id, - queryKey: ["home", id], - queryFn: async () => { - if (section.items) { - const response = await getItemsApi(api).getItems({ - userId: user?.Id, - limit: section.items?.limit || 25, - 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 || []; - } else if (section.nextUp) { - const response = await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount"], - limit: section.items?.limit || 25, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - enableResumable: section.items?.enableResumable || false, - enableRewatching: section.items?.enableRewatching || false, - }); - return response.data.Items || []; - } - return []; - }, - type: "ScrollingCollectionList", - orientation: section?.orientation || "vertical", - }); - } - return ss; - }, [api, user?.Id, settings.home?.sections]); - } - - if (isConnected === false) { - return ( - - {t("home.no_internet")} - - {t("home.no_internet_message")} - - - - - - - ); - } - - if (e1) - return ( - - {t("home.oops")} - - {t("home.error_message")} - - - ); - - // this spinner should only show up, when user navigates here - // on launch the splash screen is used for loading - if (l1 && !splashScreenVisible) - return ( - - - - ); - - return ( - - } - contentContainerStyle={{ - paddingLeft: insets.left, - paddingRight: insets.right, - paddingBottom: 16, - }} - > - - - - {sections.map((section, index) => { - if (section.type === "ScrollingCollectionList") { - return ( - - ); - } else if (section.type === "MediaListSection") { - return ( - - ); - } - return null; - })} - - - ); -} - -// Function to get suggestions -async function getSuggestions(api: Api, userId: string | undefined) { - if (!userId) return []; - const response = await getSuggestionsApi(api).getSuggestions({ - userId, - limit: 10, - mediaType: ["Unknown"], - type: ["Series"], - }); - return response.data.Items ?? []; -} - -// Function to get the next up TV show for a series -async function getNextUp( - api: Api, - userId: string | undefined, - seriesId: string | undefined -) { - if (!userId || !seriesId) return null; - const response = await getTvShowsApi(api).getNextUp({ - userId, - seriesId, - limit: 1, - }); - return response.data.Items?.[0] ?? null; +export default function page() { + return ; } diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 5828c88f..aba54ae1 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -1,8 +1,9 @@ -import { Platform } from "react-native"; import { Text } from "@/components/common/Text"; import { ListGroup } from "@/components/list/ListGroup"; import { ListItem } from "@/components/list/ListItem"; +import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector"; import { AudioToggles } from "@/components/settings/AudioToggles"; +import DownloadSettings from "@/components/settings/DownloadSettings"; import { MediaProvider } from "@/components/settings/MediaContext"; import { MediaToggles } from "@/components/settings/MediaToggles"; import { OtherSettings } from "@/components/settings/OtherSettings"; @@ -10,20 +11,16 @@ import { PluginSettings } from "@/components/settings/PluginSettings"; import { QuickConnect } from "@/components/settings/QuickConnect"; import { StorageSettings } from "@/components/settings/StorageSettings"; import { SubtitleToggles } from "@/components/settings/SubtitleToggles"; -import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector"; import { UserInfo } from "@/components/settings/UserInfo"; +import { useHaptic } from "@/hooks/useHaptic"; import { useJellyfin } from "@/providers/JellyfinProvider"; import { clearLogs } from "@/utils/log"; -import { useHaptic } from "@/hooks/useHaptic"; +import { storage } from "@/utils/mmkv"; import { useNavigation, useRouter } from "expo-router"; import { t } from "i18next"; -import React, { lazy, useEffect } from "react"; +import React, { useEffect } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { storage } from "@/utils/mmkv"; -const DownloadSettings = lazy( - () => import("@/components/settings/DownloadSettings") -); export default function settings() { const router = useRouter(); @@ -72,7 +69,7 @@ export default function settings() { - {!Platform.isTV && } + diff --git a/app/login.tsx b/app/login.tsx index b55f4b1f..662a48e0 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -9,7 +9,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; -import { useAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import React, { useCallback, useEffect, useState } from "react"; import { Alert, @@ -21,22 +21,26 @@ import { } from "react-native"; import { z } from "zod"; -import { t } from 'i18next'; +import { t } from "i18next"; const CredentialsSchema = z.object({ - username: z.string().min(1, t("login.username_required")),}); + username: z.string().min(1, t("login.username_required")), +}); - const Login: React.FC = () => { +const Login: React.FC = () => { + const api = useAtomValue(apiAtom); + const navigation = useNavigation(); + const params = useLocalSearchParams(); const { setServer, login, removeServer, initiateQuickConnect } = useJellyfin(); - const [api] = useAtom(apiAtom); - const params = useLocalSearchParams(); const { apiUrl: _apiUrl, username: _username, password: _password, } = params as { apiUrl: string; username: string; password: string }; - + + const [loadingServerCheck, setLoadingServerCheck] = useState(false); + const [loading, setLoading] = useState(false); const [serverURL, setServerURL] = useState(_apiUrl); const [serverName, setServerName] = useState(""); const [credentials, setCredentials] = useState<{ @@ -47,10 +51,11 @@ const CredentialsSchema = z.object({ password: _password, }); + /** + * A way to auto login based on a link + */ useEffect(() => { (async () => { - // we might re-use the checkUrl function here to check the url as well - // however, I don't think it should be necessary for now if (_apiUrl) { setServer({ address: _apiUrl, @@ -66,7 +71,6 @@ const CredentialsSchema = z.object({ })(); }, [_apiUrl, _username, _password]); - const navigation = useNavigation(); useEffect(() => { navigation.setOptions({ headerTitle: serverName, @@ -79,14 +83,14 @@ const CredentialsSchema = z.object({ className="flex flex-row items-center" > - {t("login.change_server")} + + {t("login.change_server")} + ) : null, }); }, [serverName, navigation, api?.basePath]); - const [loading, setLoading] = useState(false); - const handleLogin = async () => { setLoading(true); try { @@ -98,14 +102,16 @@ const CredentialsSchema = z.object({ if (error instanceof Error) { Alert.alert(t("login.connection_failed"), error.message); } else { - Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured")); + Alert.alert( + t("login.connection_failed"), + t("login.an_unexpected_error_occured") + ); } } finally { setLoading(false); } }; - const [loadingServerCheck, setLoadingServerCheck] = useState(false); /** * Checks the availability and validity of a Jellyfin server URL. @@ -180,14 +186,21 @@ const CredentialsSchema = z.object({ try { const code = await initiateQuickConnect(); if (code) { - Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [ - { - text: t("login.got_it"), - }, - ]); + Alert.alert( + t("login.quick_connect"), + t("login.enter_code_to_login", { code: code }), + [ + { + text: t("login.got_it"), + }, + ] + ); } } catch (error) { - Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect")); + Alert.alert( + t("login.error_title"), + t("login.failed_to_initiate_quick_connect") + ); } }; @@ -201,16 +214,18 @@ const CredentialsSchema = z.object({ - - <> - {serverName ? ( - <> - {t("login.login_to_title") + " "} - {serverName} - - ) : t("login.login_title")} - - + + <> + {serverName ? ( + <> + {t("login.login_to_title") + " "} + {serverName} + + ) : ( + t("login.login_title") + )} + + {api.basePath} diff --git a/bun.lock b/bun.lock index 0d3949aa..25e70592 100644 --- a/bun.lock +++ b/bun.lock @@ -60,7 +60,7 @@ "react-i18next": "^15.4.0", "react-native": "npm:react-native-tvos@~0.77.0-0", "react-native-awesome-slider": "^2.9.0", - "react-native-bottom-tabs": "0.8.6", + "react-native-bottom-tabs": "0.8.7", "react-native-circular-progress": "^1.4.1", "react-native-compressor": "^1.10.3", "react-native-country-flag": "^2.0.2", @@ -1826,7 +1826,7 @@ "react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="], - "react-native-bottom-tabs": ["react-native-bottom-tabs@0.8.6", "", { "dependencies": { "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-N5b3MoSfsEqlmvFyIyL0X0bd+QAtB+cXH1rl/+R2Kr0BefBTC7ZldGcPhgK3FhBbt0vJDpd3kLb/dvmqZd+Eag=="], + "react-native-bottom-tabs": ["react-native-bottom-tabs@0.8.7", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-cVQYs4r8Hb9V9oOO/SqsmBaZ7IzE/3Tpvz4mmRjNXKi1cBWC+ZpKTuqRx6EPjBCYTVK+vbAfoTM6IHS+6NVg4w=="], "react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="], diff --git a/components/settings/DownloadSettings.tv.tsx b/components/settings/DownloadSettings.tv.tsx new file mode 100644 index 00000000..8cd6fa73 --- /dev/null +++ b/components/settings/DownloadSettings.tv.tsx @@ -0,0 +1,5 @@ +import React from "react"; + +export default function DownloadSettings({ ...props }) { + return <>; +} diff --git a/components/settings/SettingsIndex.tsx b/components/settings/SettingsIndex.tsx new file mode 100644 index 00000000..e9213952 --- /dev/null +++ b/components/settings/SettingsIndex.tsx @@ -0,0 +1,495 @@ +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 { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { + useSplashScreenLoading, + useSplashScreenVisible, +} from "@/providers/SplashScreenProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { Feather, Ionicons } from "@expo/vector-icons"; +import { Api } from "@jellyfin/sdk"; +import { + 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 } from "@tanstack/react-query"; +import { useNavigation, useRouter } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + 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 const SettingsIndex = () => { + const router = useRouter(); + + const { t } = useTranslation(); + + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + + const [loading, setLoading] = useState(false); + const [ + settings, + updateSettings, + pluginSettings, + setPluginSettings, + refreshStreamyfinPluginSettings, + ] = useSettings(); + + const [isConnected, setIsConnected] = useState(null); + const [loadingRetry, setLoadingRetry] = useState(false); + + const navigation = useNavigation(); + + const insets = useSafeAreaInsets(); + + const { downloadedFiles, cleanCacheDirectory } = useDownload(); + useEffect(() => { + const hasDownloads = downloadedFiles && downloadedFiles.length > 0; + navigation.setOptions({ + headerLeft: () => ( + { + router.push("/(auth)/downloads"); + }} + className="p-2" + > + + + ), + }); + }, [downloadedFiles, navigation, router]); + + useEffect(() => { + cleanCacheDirectory().catch((e) => + console.error("Something went wrong cleaning cache directory") + ); + }, []); + + const checkConnection = useCallback(async () => { + setLoadingRetry(true); + const state = await NetInfo.fetch(); + setIsConnected(state.isConnected); + setLoadingRetry(false); + }, []); + + useEffect(() => { + const unsubscribe = NetInfo.addEventListener((state) => { + if (state.isConnected == false || state.isInternetReachable === false) + setIsConnected(false); + else setIsConnected(true); + }); + + NetInfo.fetch().then((state) => { + setIsConnected(state.isConnected); + }); + + // cleanCacheDirectory().catch((e) => + // console.error("Something went wrong cleaning cache directory") + // ); + + return () => { + unsubscribe(); + }; + }, []); + + 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, + }); + + // show splash screen until query loaded + useSplashScreenLoading(l1); + const splashScreenVisible = useSplashScreenVisible(); + + 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 invalidateCache = useInvalidatePlaybackProgressCache(); + + const refetch = useCallback(async () => { + setLoading(true); + await refreshStreamyfinPluginSettings(); + await invalidateCache(); + setLoading(false); + }, []); + + 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: 20, + fields: ["PrimaryImageAspectRatio", "Path"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes, + parentId, + }) + ).data || [] + ); + }, + type: "ScrollingCollectionList", + }), + [api, user?.Id] + ); + + let sections: Section[] = []; + if (!settings?.home || !settings?.home?.sections) { + sections = useMemo(() => { + if (!api || !user?.Id) return []; + + const latestMediaViews = collections.map((c) => { + const includeItemTypes: BaseItemKind[] = + c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; + const title = t("home.recently_added_in", { libraryName: c.Name }); + const queryKey = [ + "home", + "recentlyAddedIn" + c.CollectionType, + user?.Id!, + c.Id!, + ]; + return createCollectionConfig( + title || "", + queryKey, + includeItemTypes, + c.Id + ); + }); + + const ss: Section[] = [ + { + title: t("home.continue_watching"), + queryKey: ["home", "resumeItems"], + queryFn: async () => + ( + await getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + { + title: t("home.next_up"), + queryKey: ["home", "nextUp-all"], + 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: t("home.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: t("home.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]); + } else { + sections = useMemo(() => { + if (!api || !user?.Id) return []; + const ss: Section[] = []; + + for (const key in settings.home?.sections) { + // @ts-expect-error + const section = settings.home?.sections[key]; + const id = section.title || key; + ss.push({ + title: id, + queryKey: ["home", id], + queryFn: async () => { + if (section.items) { + const response = await getItemsApi(api).getItems({ + userId: user?.Id, + limit: section.items?.limit || 25, + 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 || []; + } else if (section.nextUp) { + const response = await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + fields: ["MediaSourceCount"], + limit: section.items?.limit || 25, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: section.items?.enableResumable || false, + enableRewatching: section.items?.enableRewatching || false, + }); + return response.data.Items || []; + } + return []; + }, + type: "ScrollingCollectionList", + orientation: section?.orientation || "vertical", + }); + } + return ss; + }, [api, user?.Id, settings.home?.sections]); + } + + if (isConnected === false) { + return ( + + {t("home.no_internet")} + + {t("home.no_internet_message")} + + + + + + + ); + } + + if (e1) + return ( + + {t("home.oops")} + + {t("home.error_message")} + + + ); + + // this spinner should only show up, when user navigates here + // on launch the splash screen is used for loading + if (l1 && !splashScreenVisible) + return ( + + + + ); + + return ( + + } + contentContainerStyle={{ + paddingLeft: insets.left, + paddingRight: insets.right, + paddingBottom: 16, + }} + > + + + + {sections.map((section, index) => { + if (section.type === "ScrollingCollectionList") { + return ( + + ); + } else if (section.type === "MediaListSection") { + return ( + + ); + } + return null; + })} + + + ); +}; + +// Function to get suggestions +async function getSuggestions(api: Api, userId: string | undefined) { + if (!userId) return []; + const response = await getSuggestionsApi(api).getSuggestions({ + userId, + limit: 10, + mediaType: ["Unknown"], + type: ["Series"], + }); + return response.data.Items ?? []; +} + +// Function to get the next up TV show for a series +async function getNextUp( + api: Api, + userId: string | undefined, + seriesId: string | undefined +) { + if (!userId || !seriesId) return null; + const response = await getTvShowsApi(api).getNextUp({ + userId, + seriesId, + limit: 1, + }); + return response.data.Items?.[0] ?? null; +} diff --git a/components/settings/SettingsIndex.tv.tsx b/components/settings/SettingsIndex.tv.tsx new file mode 100644 index 00000000..905baad3 --- /dev/null +++ b/components/settings/SettingsIndex.tv.tsx @@ -0,0 +1,463 @@ +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 { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { + useSplashScreenLoading, + useSplashScreenVisible, +} from "@/providers/SplashScreenProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { Ionicons } from "@expo/vector-icons"; +import { Api } from "@jellyfin/sdk"; +import { + 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 } from "@tanstack/react-query"; +import { useRouter } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + RefreshControl, + ScrollView, + 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 const SettingsIndex = () => { + const router = useRouter(); + + const { t } = useTranslation(); + + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + + const [loading, setLoading] = useState(false); + const [ + settings, + updateSettings, + pluginSettings, + setPluginSettings, + refreshStreamyfinPluginSettings, + ] = useSettings(); + + const [isConnected, setIsConnected] = useState(null); + const [loadingRetry, setLoadingRetry] = useState(false); + + const insets = useSafeAreaInsets(); + + const checkConnection = useCallback(async () => { + setLoadingRetry(true); + const state = await NetInfo.fetch(); + setIsConnected(state.isConnected); + setLoadingRetry(false); + }, []); + + useEffect(() => { + const unsubscribe = NetInfo.addEventListener((state) => { + if (state.isConnected == false || state.isInternetReachable === false) + setIsConnected(false); + else setIsConnected(true); + }); + + NetInfo.fetch().then((state) => { + setIsConnected(state.isConnected); + }); + + // cleanCacheDirectory().catch((e) => + // console.error("Something went wrong cleaning cache directory") + // ); + + return () => { + unsubscribe(); + }; + }, []); + + 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, + }); + + // show splash screen until query loaded + useSplashScreenLoading(l1); + const splashScreenVisible = useSplashScreenVisible(); + + 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 invalidateCache = useInvalidatePlaybackProgressCache(); + + const refetch = useCallback(async () => { + setLoading(true); + await refreshStreamyfinPluginSettings(); + await invalidateCache(); + setLoading(false); + }, []); + + 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: 20, + fields: ["PrimaryImageAspectRatio", "Path"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes, + parentId, + }) + ).data || [] + ); + }, + type: "ScrollingCollectionList", + }), + [api, user?.Id] + ); + + let sections: Section[] = []; + if (!settings?.home || !settings?.home?.sections) { + sections = useMemo(() => { + if (!api || !user?.Id) return []; + + const latestMediaViews = collections.map((c) => { + const includeItemTypes: BaseItemKind[] = + c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; + const title = t("home.recently_added_in", { libraryName: c.Name }); + const queryKey = [ + "home", + "recentlyAddedIn" + c.CollectionType, + user?.Id!, + c.Id!, + ]; + return createCollectionConfig( + title || "", + queryKey, + includeItemTypes, + c.Id + ); + }); + + const ss: Section[] = [ + { + title: t("home.continue_watching"), + queryKey: ["home", "resumeItems"], + queryFn: async () => + ( + await getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + { + title: t("home.next_up"), + queryKey: ["home", "nextUp-all"], + 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: t("home.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: t("home.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]); + } else { + sections = useMemo(() => { + if (!api || !user?.Id) return []; + const ss: Section[] = []; + + for (const key in settings.home?.sections) { + // @ts-expect-error + const section = settings.home?.sections[key]; + const id = section.title || key; + ss.push({ + title: id, + queryKey: ["home", id], + queryFn: async () => { + if (section.items) { + const response = await getItemsApi(api).getItems({ + userId: user?.Id, + limit: section.items?.limit || 25, + 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 || []; + } else if (section.nextUp) { + const response = await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + fields: ["MediaSourceCount"], + limit: section.items?.limit || 25, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: section.items?.enableResumable || false, + enableRewatching: section.items?.enableRewatching || false, + }); + return response.data.Items || []; + } + return []; + }, + type: "ScrollingCollectionList", + orientation: section?.orientation || "vertical", + }); + } + return ss; + }, [api, user?.Id, settings.home?.sections]); + } + + if (isConnected === false) { + return ( + + {t("home.no_internet")} + + {t("home.no_internet_message")} + + + + + + + ); + } + + if (e1) + return ( + + {t("home.oops")} + + {t("home.error_message")} + + + ); + + // this spinner should only show up, when user navigates here + // on launch the splash screen is used for loading + if (l1 && !splashScreenVisible) + return ( + + + + ); + + return ( + + } + contentContainerStyle={{ + paddingLeft: insets.left, + paddingRight: insets.right, + paddingBottom: 16, + }} + > + + + + {sections.map((section, index) => { + if (section.type === "ScrollingCollectionList") { + return ( + + ); + } else if (section.type === "MediaListSection") { + return ( + + ); + } + return null; + })} + + + ); +}; + +// Function to get suggestions +async function getSuggestions(api: Api, userId: string | undefined) { + if (!userId) return []; + const response = await getSuggestionsApi(api).getSuggestions({ + userId, + limit: 10, + mediaType: ["Unknown"], + type: ["Series"], + }); + return response.data.Items ?? []; +} + +// Function to get the next up TV show for a series +async function getNextUp( + api: Api, + userId: string | undefined, + seriesId: string | undefined +) { + if (!userId || !seriesId) return null; + const response = await getTvShowsApi(api).getNextUp({ + userId, + seriesId, + limit: 1, + }); + return response.data.Items?.[0] ?? null; +} diff --git a/package.json b/package.json index 62ae1e30..ee7d0bba 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "react-i18next": "^15.4.0", "react-native": "npm:react-native-tvos@~0.77.0-0", "react-native-awesome-slider": "^2.9.0", - "react-native-bottom-tabs": "0.8.6", + "react-native-bottom-tabs": "0.8.7", "react-native-circular-progress": "^1.4.1", "react-native-compressor": "^1.10.3", "react-native-country-flag": "^2.0.2", diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 39feb457..d59ebdeb 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -18,11 +18,13 @@ import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; +import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader"; import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query"; import axios from "axios"; import * as Application from "expo-application"; import * as FileSystem from "expo-file-system"; import { FileInfo } from "expo-file-system"; +import Notifications from "expo-notifications"; import { useRouter } from "expo-router"; import { atom, useAtom } from "jotai"; import React, { @@ -36,11 +38,6 @@ import { useTranslation } from "react-i18next"; import { AppState, AppStateStatus, Platform } from "react-native"; import { toast } from "sonner-native"; import { apiAtom } from "./JellyfinProvider"; -const BackGroundDownloader = !Platform.isTV - ? (require("@kesha-antonov/react-native-background-downloader") as typeof import("@kesha-antonov/react-native-background-downloader")) - : null; -// import * as Notifications from "expo-notifications"; -const Notifications = !Platform.isTV ? require("expo-notifications") : null; export type DownloadedItem = { item: Partial; @@ -58,8 +55,6 @@ const DownloadContext = createContext | null>(null); function useDownloadProvider() { - if (Platform.isTV) return; - const queryClient = useQueryClient(); const { t } = useTranslation(); const [settings] = useSettings(); @@ -747,5 +742,8 @@ export function useDownload() { if (context === null) { throw new Error("useDownload must be used within a DownloadProvider"); } + if (Platform.isTV) { + throw new Error("useDownload is not supported on TVOS"); + } return context; } diff --git a/providers/DownloadProvider.tv.tsx b/providers/DownloadProvider.tv.tsx new file mode 100644 index 00000000..80d72026 --- /dev/null +++ b/providers/DownloadProvider.tv.tsx @@ -0,0 +1,107 @@ +import { storage } from "@/utils/mmkv"; +import { JobStatus } from "@/utils/optimize-server"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import * as Application from "expo-application"; +import * as FileSystem from "expo-file-system"; +import { atom, useAtom } from "jotai"; +import React, { createContext, useCallback, useContext, useMemo } from "react"; + +export type DownloadedItem = { + item: Partial; + mediaSource: MediaSourceInfo; +}; + +export const processesAtom = atom([]); + +const DownloadContext = createContext | null>(null); + +/** + * Dummy download provider for tvOS + */ +function useDownloadProvider() { + const [processes, setProcesses] = useAtom(processesAtom); + + const downloadedFiles: DownloadedItem[] = []; + + const removeProcess = useCallback(async (id: string) => {}, []); + + const startDownload = useCallback(async (process: JobStatus) => { + return null; + }, []); + + const startBackgroundDownload = useCallback( + async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => { + return null; + }, + [] + ); + + const deleteAllFiles = async (): Promise => {}; + + const deleteFile = async (id: string): Promise => {}; + + const deleteItems = async (items: BaseItemDto[]) => {}; + + const cleanCacheDirectory = async () => {}; + + const deleteFileByType = async (type: BaseItemDto["Type"]) => {}; + + const appSizeUsage = useMemo(async () => { + return 0; + }, []); + + function getDownloadedItem(itemId: string): DownloadedItem | null { + return null; + } + + function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {} + + function getDownloadedItemSize(itemId: string): number { + const size = storage.getString("downloadedItemSize-" + itemId); + return size ? parseInt(size) : 0; + } + + const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`; + + return { + processes, + startBackgroundDownload, + downloadedFiles, + deleteAllFiles, + deleteFile, + deleteItems, + saveDownloadedItemInfo, + removeProcess, + setProcesses, + startDownload, + getDownloadedItem, + deleteFileByType, + appSizeUsage, + getDownloadedItemSize, + APP_CACHE_DOWNLOAD_DIRECTORY, + cleanCacheDirectory, + }; +} + +export function DownloadProvider({ children }: { children: React.ReactNode }) { + const downloadProviderValue = useDownloadProvider(); + + return ( + + {children} + + ); +} + +export function useDownload() { + const context = useContext(DownloadContext); + if (context === null) { + throw new Error("useDownload must be used within a DownloadProvider"); + } + return context; +} diff --git a/tsconfig.json b/tsconfig.json index 909e9010..ce27fee3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,15 +3,8 @@ "compilerOptions": { "strict": true, "paths": { - "@/*": [ - "./*" - ] + "@/*": ["./*"] } }, - "include": [ - "**/*.ts", - "**/*.tsx", - ".expo/types/**/*.ts", - "expo-env.d.ts" - ] + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] }