diff --git a/.gitignore b/.gitignore index 7c36d0f9..2813f770 100644 --- a/.gitignore +++ b/.gitignore @@ -64,4 +64,5 @@ credentials.json streamyfin-4fec1-firebase-adminsdk.json # Version and Backup Files -/version-backup-* \ No newline at end of file +/version-backup-* +modules/background-downloader/android/build/* diff --git a/GLOBAL_MODAL_GUIDE.md b/GLOBAL_MODAL_GUIDE.md new file mode 100644 index 00000000..5426ca72 --- /dev/null +++ b/GLOBAL_MODAL_GUIDE.md @@ -0,0 +1,232 @@ +# Global Modal System with Gorhom Bottom Sheet + +This guide explains how to use the global modal system implemented in this project. + +## Overview + +The global modal system allows you to trigger a bottom sheet modal from anywhere in your app programmatically, and render any component inside it. + +## Architecture + +The system consists of three main parts: + +1. **GlobalModalProvider** (`providers/GlobalModalProvider.tsx`) - Context provider that manages modal state +2. **GlobalModal** (`components/GlobalModal.tsx`) - The actual modal component rendered at root level +3. **useGlobalModal** hook - Hook to interact with the modal from anywhere + +## Setup (Already Configured) + +The system is already integrated into your app: + +```tsx +// In app/_layout.tsx + + + {/* Your app content */} + + + +``` + +## Usage + +### Basic Usage + +```tsx +import { useGlobalModal } from "@/providers/GlobalModalProvider"; +import { View, Text } from "react-native"; + +function MyComponent() { + const { showModal, hideModal } = useGlobalModal(); + + const handleOpenModal = () => { + showModal( + + Hello from Modal! + + ); + }; + + return ( + - - {otherMedia.length > 0 && ( - - )} - + + {otherMedia.length > 0 && ( + + + + {t("home.downloads.other_media")} + + + {otherMedia?.length} + + + + + {otherMedia?.map((item) => ( + + + + ))} + + - - - + )} + {downloadedFiles?.length === 0 && ( + + + {t("home.downloads.no_downloaded_items")} + + + )} + + ); } diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index dc04e43b..ad951c36 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -1,5 +1,16 @@ -import { HomeIndex } from "@/components/settings/HomeIndex"; +import { useSettings } from "@/utils/atoms/settings"; +import { Home } from "../../../../components/home/Home"; +import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel"; -export default function page() { - return ; -} +const Index = () => { + const { settings } = useSettings(); + const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false; + + if (showLargeHomeCarousel) { + return ; + } + + return ; +}; + +export default Index; diff --git a/app/(auth)/(tabs)/(home)/sessions/index.tsx b/app/(auth)/(tabs)/(home)/sessions/index.tsx index cd5e32a0..0ed8fc94 100644 --- a/app/(auth)/(tabs)/(home)/sessions/index.tsx +++ b/app/(auth)/(tabs)/(home)/sessions/index.tsx @@ -1,11 +1,9 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; -import { - HardwareAccelerationType, - type SessionInfoDto, -} from "@jellyfin/sdk/lib/generated-client"; +import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client"; import { GeneralCommandType, PlaystateCommand, + SessionInfoDto, } from "@jellyfin/sdk/lib/generated-client/models"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { FlashList } from "@shopify/flash-list"; @@ -13,7 +11,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { TouchableOpacity, View } from "react-native"; +import { Platform, TouchableOpacity, View } from "react-native"; import { Badge } from "@/components/Badge"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; @@ -49,14 +47,13 @@ export default function page() { } keyExtractor={(item) => item.Id || ""} - estimatedItemSize={200} /> ); } diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 91f569df..ae1d00ee 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -46,7 +46,7 @@ export default function settings() { logout(); }} > - + {t("home.settings.log_out_button")} @@ -56,12 +56,16 @@ export default function settings() { return ( - + @@ -115,6 +119,7 @@ export default function settings() { {!Platform.isTV && } + ); diff --git a/app/(auth)/(tabs)/(home)/settings/logs/page.tsx b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx index 7af8fc03..d2b9b852 100644 --- a/app/(auth)/(tabs)/(home)/settings/logs/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx @@ -1,4 +1,3 @@ -import * as FileSystem from "expo-file-system"; import { useNavigation } from "expo-router"; import * as Sharing from "expo-sharing"; import { useCallback, useEffect, useId, useMemo, useState } from "react"; diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx index 798fa2cc..1723fe4b 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx @@ -16,6 +16,7 @@ import type React from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { FlatList, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { FilterButton } from "@/components/filters/FilterButton"; @@ -204,154 +205,154 @@ const page: React.FC = () => { const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); + const _insets = useSafeAreaInsets(); + const ListHeaderComponent = useCallback( () => ( - - , - }, - { - key: "genre", - component: ( - { - if (!api) return null; - const response = await getFilterApi( - api, - ).getQueryFiltersLegacy({ - userId: user?.Id, - parentId: collectionId, - }); - return response.data.Genres || []; - }} - set={setSelectedGenres} - values={selectedGenres} - title={t("library.filters.genres")} - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - ), - }, - { - key: "year", - component: ( - { - if (!api) return null; - const response = await getFilterApi( - api, - ).getQueryFiltersLegacy({ - userId: user?.Id, - parentId: collectionId, - }); - return response.data.Years || []; - }} - set={setSelectedYears} - values={selectedYears} - title={t("library.filters.years")} - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => item.includes(search)} - /> - ), - }, - { - key: "tags", - component: ( - { - if (!api) return null; - const response = await getFilterApi( - api, - ).getQueryFiltersLegacy({ - userId: user?.Id, - parentId: collectionId, - }); - return response.data.Tags || []; - }} - set={setSelectedTags} - values={selectedTags} - title={t("library.filters.tags")} - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - ), - }, - { - key: "sortBy", - component: ( - sortOptions.map((s) => s.key)} - set={setSortBy} - values={sortBy} - title={t("library.filters.sort_by")} - renderItemLabel={(item) => - sortOptions.find((i) => i.key === item)?.value || "" - } - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - ), - }, - { - key: "sortOrder", - component: ( - sortOrderOptions.map((s) => s.key)} - set={setSortOrder} - values={sortOrder} - title={t("library.filters.sort_order")} - renderItemLabel={(item) => - sortOrderOptions.find((i) => i.key === item)?.value || "" - } - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - ), - }, - ]} - renderItem={({ item }) => item.component} - keyExtractor={(item) => item.key} - /> - + , + }, + { + key: "genre", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api, + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Genres || []; + }} + set={setSelectedGenres} + values={selectedGenres} + title={t("library.filters.genres")} + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "year", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api, + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Years || []; + }} + set={setSelectedYears} + values={selectedYears} + title={t("library.filters.years")} + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => item.includes(search)} + /> + ), + }, + { + key: "tags", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api, + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Tags || []; + }} + set={setSelectedTags} + values={selectedTags} + title={t("library.filters.tags")} + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortBy", + component: ( + sortOptions.map((s) => s.key)} + set={setSortBy} + values={sortBy} + title={t("library.filters.sort_by")} + renderItemLabel={(item) => + sortOptions.find((i) => i.key === item)?.value || "" + } + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortOrder", + component: ( + sortOrderOptions.map((s) => s.key)} + set={setSortOrder} + values={sortOrder} + title={t("library.filters.sort_order")} + renderItemLabel={(item) => + sortOrderOptions.find((i) => i.key === item)?.value || "" + } + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + ]} + renderItem={({ item }) => item.component} + keyExtractor={(item) => item.key} + /> ), [ collectionId, @@ -393,7 +394,6 @@ const page: React.FC = () => { data={flatData} renderItem={renderItem} keyExtractor={keyExtractor} - estimatedItemSize={255} numColumns={ orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5 } diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx index 12e1c82e..28533cd0 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx @@ -19,31 +19,29 @@ import { Text } from "@/components/common/Text"; import { GenreTags } from "@/components/GenreTags"; import Cast from "@/components/jellyseerr/Cast"; import DetailFacts from "@/components/jellyseerr/DetailFacts"; +import RequestModal from "@/components/jellyseerr/RequestModal"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { PlatformDropdown } from "@/components/PlatformDropdown"; import { JellyserrRatings } from "@/components/Ratings"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; import { ItemActions } from "@/components/series/SeriesActions"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; +import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { type IssueType, IssueTypeName, } from "@/utils/jellyseerr/server/constants/issue"; import { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieResult, TvResult, } from "@/utils/jellyseerr/server/models/Search"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - -import RequestModal from "@/components/jellyseerr/RequestModal"; -import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; -import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; -import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; - const Page: React.FC = () => { const insets = useSafeAreaInsets(); const params = useLocalSearchParams(); @@ -65,6 +63,7 @@ const Page: React.FC = () => { const [issueType, setIssueType] = useState(); const [issueMessage, setIssueMessage] = useState(); const [requestBody, _setRequestBody] = useState(); + const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false); const advancedReqModalRef = useRef(null); const bottomSheetModalRef = useRef(null); @@ -115,6 +114,10 @@ const Page: React.FC = () => { } }, [jellyseerrApi, details, result, issueType, issueMessage]); + const handleIssueModalDismiss = useCallback(() => { + setIssueTypeDropdownOpen(false); + }, []); + const setRequestBody = useCallback( (body: MediaRequestBody) => { _setRequestBody(body); @@ -156,11 +159,31 @@ const Page: React.FC = () => { [details], ); + const issueTypeOptionGroups = useMemo( + () => [ + { + title: t("jellyseerr.types"), + options: Object.entries(IssueTypeName) + .reverse() + .map(([key, value]) => ({ + type: "radio" as const, + label: value, + value: key, + selected: key === String(issueType), + onPress: () => setIssueType(key as unknown as IssueType), + })), + }, + ], + [issueType, t], + ); + useEffect(() => { if (details) { navigation.setOptions({ headerRight: () => ( - + ), @@ -355,6 +378,8 @@ const Page: React.FC = () => { backgroundColor: "#171717", }} backdropComponent={renderBackdrop} + stackBehavior='push' + onDismiss={handleIssueModalDismiss} > @@ -364,50 +389,25 @@ const Page: React.FC = () => { - - - - - - {t("jellyseerr.issue_type")} + + + {t("jellyseerr.issue_type")} + + + + {issueType + ? IssueTypeName[issueType] + : t("jellyseerr.select_an_issue")} - - - {issueType - ? IssueTypeName[issueType] - : t("jellyseerr.select_an_issue")} - - - - - - {t("jellyseerr.types")} - - {Object.entries(IssueTypeName) - .reverse() - .map(([key, value], _idx) => ( - - setIssueType(key as unknown as IssueType) - } - > - - {value} - - - ))} - - + } + title={t("jellyseerr.types")} + open={issueTypeDropdownOpen} + onOpenChange={setIssueTypeDropdownOpen} + /> diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx index 78cad4a3..2636d5c0 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx @@ -65,9 +65,11 @@ const page: React.FC = () => { const { data: allEpisodes, isLoading } = useQuery({ queryKey: ["AllEpisodes", item?.Id], queryFn: async () => { - const res = await getTvShowsApi(api!).getEpisodes({ - seriesId: item?.Id!, - userId: user?.Id!, + if (!api || !user?.Id || !item?.Id) return []; + + const res = await getTvShowsApi(api).getEpisodes({ + seriesId: item.Id, + userId: user.Id, enableUserData: true, // Note: Including trick play is necessary to enable trick play downloads fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index d795fb91..481881fc 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -271,145 +271,143 @@ const Page = () => { const ListHeaderComponent = useCallback( () => ( - - , - }, - { - key: "genre", - component: ( - { - if (!api) return null; - const response = await getFilterApi( - api, - ).getQueryFiltersLegacy({ - userId: user?.Id, - parentId: libraryId, - }); - return response.data.Genres || []; - }} - set={setSelectedGenres} - values={selectedGenres} - title={t("library.filters.genres")} - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - ), - }, - { - key: "year", - component: ( - { - if (!api) return null; - const response = await getFilterApi( - api, - ).getQueryFiltersLegacy({ - userId: user?.Id, - parentId: libraryId, - }); - return response.data.Years || []; - }} - set={setSelectedYears} - values={selectedYears} - title={t("library.filters.years")} - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => item.includes(search)} - /> - ), - }, - { - key: "tags", - component: ( - { - if (!api) return null; - const response = await getFilterApi( - api, - ).getQueryFiltersLegacy({ - userId: user?.Id, - parentId: libraryId, - }); - return response.data.Tags || []; - }} - set={setSelectedTags} - values={selectedTags} - title={t("library.filters.tags")} - renderItemLabel={(item) => item.toString()} - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - ), - }, - { - key: "sortBy", - component: ( - sortOptions.map((s) => s.key)} - set={setSortBy} - values={sortBy} - title={t("library.filters.sort_by")} - renderItemLabel={(item) => - sortOptions.find((i) => i.key === item)?.value || "" - } - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - ), - }, - { - key: "sortOrder", - component: ( - sortOrderOptions.map((s) => s.key)} - set={setSortOrder} - values={sortOrder} - title={t("library.filters.sort_order")} - renderItemLabel={(item) => - sortOrderOptions.find((i) => i.key === item)?.value || "" - } - searchFilter={(item, search) => - item.toLowerCase().includes(search.toLowerCase()) - } - /> - ), - }, - ]} - renderItem={({ item }) => item.component} - keyExtractor={(item) => item.key} - /> - + , + }, + { + key: "genre", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api, + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: libraryId, + }); + return response.data.Genres || []; + }} + set={setSelectedGenres} + values={selectedGenres} + title={t("library.filters.genres")} + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "year", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api, + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: libraryId, + }); + return response.data.Years || []; + }} + set={setSelectedYears} + values={selectedYears} + title={t("library.filters.years")} + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => item.includes(search)} + /> + ), + }, + { + key: "tags", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api, + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: libraryId, + }); + return response.data.Tags || []; + }} + set={setSelectedTags} + values={selectedTags} + title={t("library.filters.tags")} + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortBy", + component: ( + sortOptions.map((s) => s.key)} + set={setSortBy} + values={sortBy} + title={t("library.filters.sort_by")} + renderItemLabel={(item) => + sortOptions.find((i) => i.key === item)?.value || "" + } + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortOrder", + component: ( + sortOrderOptions.map((s) => s.key)} + set={setSortOrder} + values={sortOrder} + title={t("library.filters.sort_order")} + renderItemLabel={(item) => + sortOrderOptions.find((i) => i.key === item)?.value || "" + } + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + ]} + renderItem={({ item }) => item.component} + keyExtractor={(item) => item.key} + /> ), [ libraryId, @@ -453,7 +451,6 @@ const Page = () => { renderItem={renderItem} extraData={[orientation, nrOfCols]} keyExtractor={keyExtractor} - estimatedItemSize={244} numColumns={nrOfCols} onEndReached={() => { if (hasNextPage) { diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx index 8c7fb259..ebf77b66 100644 --- a/app/(auth)/(tabs)/(libraries)/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -1,85 +1,166 @@ import { Ionicons } from "@expo/vector-icons"; import { Stack } from "expo-router"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Platform, TouchableOpacity } from "react-native"; -import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet"; +import { Platform, View } from "react-native"; +import { PlatformDropdown } from "@/components/PlatformDropdown"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { useSettings } from "@/utils/atoms/settings"; export default function IndexLayout() { const { settings, updateSettings, pluginSettings } = useSettings(); - const [optionsSheetOpen, setOptionsSheetOpen] = useState(false); const { t } = useTranslation(); if (!settings?.libraryOptions) return null; return ( - <> - - - !pluginSettings?.libraryOptions?.locked && - !Platform.isTV && ( - setOptionsSheetOpen(true)} - className='flex flex-row items-center justify-center w-9 h-9' - > - - - ), - }} - /> - - {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( - - ))} - - - - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - ...options, - }, - }) - } - disabled={pluginSettings?.libraryOptions?.locked} + + + !pluginSettings?.libraryOptions?.locked && + !Platform.isTV && ( + + + + } + title={t("library.options.display")} + groups={[ + { + title: t("library.options.display"), + options: [ + { + type: "radio", + label: t("library.options.row"), + value: "row", + selected: settings.libraryOptions.display === "row", + onPress: () => + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + display: "row", + }, + }), + }, + { + type: "radio", + label: t("library.options.list"), + value: "list", + selected: settings.libraryOptions.display === "list", + onPress: () => + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + display: "list", + }, + }), + }, + ], + }, + { + title: t("library.options.image_style"), + options: [ + { + type: "radio", + label: t("library.options.poster"), + value: "poster", + selected: + settings.libraryOptions.imageStyle === "poster", + onPress: () => + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + imageStyle: "poster", + }, + }), + }, + { + type: "radio", + label: t("library.options.cover"), + value: "cover", + selected: + settings.libraryOptions.imageStyle === "cover", + onPress: () => + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + imageStyle: "cover", + }, + }), + }, + ], + }, + { + title: "Options", + options: [ + { + type: "toggle", + label: t("library.options.show_titles"), + value: settings.libraryOptions.showTitles, + onToggle: () => + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + showTitles: !settings.libraryOptions.showTitles, + }, + }), + disabled: + settings.libraryOptions.imageStyle === "poster", + }, + { + type: "toggle", + label: t("library.options.show_stats"), + value: settings.libraryOptions.showStats, + onToggle: () => + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + showStats: !settings.libraryOptions.showStats, + }, + }), + }, + ], + }, + ]} + /> + ), + }} /> - + + {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( + + ))} + + ); } diff --git a/app/(auth)/(tabs)/(libraries)/index.tsx b/app/(auth)/(tabs)/(libraries)/index.tsx index c638e5d2..b56397f9 100644 --- a/app/(auth)/(tabs)/(libraries)/index.tsx +++ b/app/(auth)/(tabs)/(libraries)/index.tsx @@ -7,7 +7,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtom } from "jotai"; import { useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { StyleSheet, View } from "react-native"; +import { Platform, StyleSheet, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; @@ -84,11 +84,11 @@ export default function index() { extraData={settings} contentInsetAdjustmentBehavior='automatic' contentContainerStyle={{ - paddingTop: 17, + paddingTop: Platform.OS === "android" ? 17 : 0, paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17, paddingBottom: 150, - paddingLeft: insets.left, - paddingRight: insets.right, + paddingLeft: insets.left + 17, + paddingRight: insets.right + 17, }} data={libraries} renderItem={({ item }) => } @@ -105,7 +105,6 @@ export default function index() { ) } - estimatedItemSize={200} /> ); } diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx index 4577a38a..f4d34978 100644 --- a/app/(auth)/(tabs)/(search)/_layout.tsx +++ b/app/(auth)/(tabs)/(search)/_layout.tsx @@ -28,7 +28,7 @@ export default function SearchLayout() { options={{ title: "", headerShown: !Platform.isTV, - headerBlurEffect: "prominent", + headerBlurEffect: "none", headerTransparent: Platform.OS === "ios", headerShadowVisible: false, }} diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 5a95ad48..bedf5ffa 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -24,8 +24,6 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; -import { FilterButton } from "@/components/filters/FilterButton"; -import { Tag } from "@/components/GenreTags"; import { ItemCardText } from "@/components/ItemCardText"; import { JellyseerrSearchSort, @@ -33,8 +31,10 @@ import { } from "@/components/jellyseerr/JellyseerrIndexPage"; import MoviePoster from "@/components/posters/MoviePoster"; import SeriesPoster from "@/components/posters/SeriesPoster"; +import { DiscoverFilters } from "@/components/search/DiscoverFilters"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { SearchItemWrapper } from "@/components/search/SearchItemWrapper"; +import { SearchTabButtons } from "@/components/search/SearchTabButtons"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; @@ -284,67 +284,30 @@ export default function search() { )} {jellyseerrApi && ( - - setSearchType("Library")}> - - - setSearchType("Discover")}> - - + + {searchType === "Discover" && !loading && noResults && debouncedSearch.length > 0 && ( - - - Object.keys(JellyseerrSearchSort).filter((v) => - Number.isNaN(Number(v)), - ) - } - set={(value) => setJellyseerrOrderBy(value[0])} - values={[jellyseerrOrderBy]} - title={t("library.filters.sort_by")} - renderItemLabel={(item) => - t(`home.settings.plugins.jellyseerr.order_by.${item}`) - } - disableSearch={true} - /> - ["asc", "desc"]} - set={(value) => setJellyseerrSortOrder(value[0])} - values={[jellyseerrSortOrder]} - title={t("library.filters.sort_order")} - renderItemLabel={(item) => t(`library.filters.${item}`)} - disableSearch={true} - /> - + )} - + )} diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index 41a76eb6..e8a79728 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -55,6 +55,7 @@ export default function TabLayout() { backgroundColor: "#121212", }} tabBarActiveTintColor={Colors.primary} + activeIndicatorColor={"#392c3b"} scrollEdgeAppearance='default' > @@ -70,10 +71,7 @@ export default function TabLayout() { tabBarIcon: Platform.OS === "android" ? (_e) => require("@/assets/icons/house.fill.png") - : ({ focused }) => - focused - ? { sfSymbol: "house.fill" } - : { sfSymbol: "house" }, + : (_e) => ({ sfSymbol: "house.fill" }), }} /> require("@/assets/icons/magnifyingglass.png") - : ({ focused }) => - focused - ? { sfSymbol: "magnifyingglass" } - : { sfSymbol: "magnifyingglass" }, + : (_e) => ({ sfSymbol: "magnifyingglass" }), }} /> - focused - ? require("@/assets/icons/heart.fill.png") - : require("@/assets/icons/heart.png") - : ({ focused }) => - focused - ? { sfSymbol: "heart.fill" } - : { sfSymbol: "heart" }, + ? (_e) => require("@/assets/icons/heart.fill.png") + : (_e) => ({ sfSymbol: "heart.fill" }), }} /> require("@/assets/icons/server.rack.png") - : ({ focused }) => - focused - ? { sfSymbol: "rectangle.stack.fill" } - : { sfSymbol: "rectangle.stack" }, + : (_e) => ({ sfSymbol: "rectangle.stack.fill" }), }} /> require("@/assets/icons/list.png") - : ({ focused }) => - focused - ? { sfSymbol: "list.dash.fill" } - : { sfSymbol: "list.dash" }, + : (_e) => ({ sfSymbol: "list.dash.fill" }), }} /> diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 587368bd..537c11a4 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -29,6 +29,7 @@ import { VLCColor, } from "@/constants/SubtitleConstants"; import { useHaptic } from "@/hooks/useHaptic"; +import { useOrientation } from "@/hooks/useOrientation"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useWebSocket } from "@/hooks/useWebsockets"; @@ -76,7 +77,10 @@ export default function page() { : require("react-native-volume-manager"); const downloadUtils = useDownload(); - const downloadedFiles = downloadUtils.getDownloadedItems(); + const downloadedFiles = useMemo( + () => downloadUtils.getDownloadedItems(), + [downloadUtils.getDownloadedItems], + ); const revalidateProgressCache = useInvalidatePlaybackProgressCache(); @@ -106,6 +110,7 @@ export default function page() { playbackPosition?: string; }>(); const { settings } = useSettings(); + const { lockOrientation, unlockOrientation } = useOrientation(); const offline = offlineStr === "true"; const playbackManager = usePlaybackManager(); @@ -168,6 +173,16 @@ export default function page() { } }, [itemId, offline, api, user?.Id]); + useEffect(() => { + if (settings?.defaultVideoOrientation) { + lockOrientation(settings.defaultVideoOrientation); + } + + return () => { + unlockOrientation(); + }; + }, [settings?.defaultVideoOrientation]); + interface Stream { mediaSource: MediaSourceInfo; sessionId: string; @@ -283,12 +298,14 @@ export default function page() { }; const reportPlaybackStopped = useCallback(async () => { + if (!item?.Id || !stream?.sessionId) return; + const currentTimeInTicks = msToTicks(progress.get()); await getPlaystateApi(api!).onPlaybackStopped({ - itemId: item?.Id!, + itemId: item.Id, mediaSourceId: mediaSourceId, positionTicks: currentTimeInTicks, - playSessionId: stream?.sessionId!, + playSessionId: stream.sessionId, }); }, [ api, @@ -319,9 +336,9 @@ export default function page() { }, [navigation, stop]); const currentPlayStateInfo = useCallback(() => { - if (!stream) return; + if (!stream || !item?.Id) return; return { - itemId: item?.Id!, + itemId: item.Id, audioStreamIndex: audioIndex ? audioIndex : undefined, subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, mediaSourceId: mediaSourceId, @@ -765,6 +782,7 @@ export default function page() { setAspectRatio={setAspectRatio} setScaleFactor={setScaleFactor} isVlc + api={api} downloadedFiles={downloadedFiles} /> )} diff --git a/app/_layout.tsx b/app/_layout.tsx index 34302de4..1a04ae9b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,18 +1,23 @@ import "@/augmentations"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; +import { DarkTheme, ThemeProvider } from "@react-navigation/native"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as BackgroundTask from "expo-background-task"; +import * as Device from "expo-device"; import { Platform } from "react-native"; +import { GlobalModal } from "@/components/GlobalModal"; import i18n from "@/i18n"; import { DownloadProvider } from "@/providers/DownloadProvider"; +import { GlobalModalProvider } from "@/providers/GlobalModalProvider"; import { apiAtom, getOrSetDeviceId, - getTokenFromStorage, JellyfinProvider, } from "@/providers/JellyfinProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider"; -import { type Settings, useSettings } from "@/utils/atoms/settings"; +import { useSettings } from "@/utils/atoms/settings"; import { BACKGROUND_FETCH_TASK, BACKGROUND_FETCH_TASK_SESSIONS, @@ -26,44 +31,30 @@ import { } from "@/utils/log"; import { storage } from "@/utils/mmkv"; -const BackGroundDownloader = !Platform.isTV - ? require("@kesha-antonov/react-native-background-downloader") - : null; - -import { DarkTheme, ThemeProvider } from "@react-navigation/native"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import * as BackgroundTask from "expo-background-task"; - -import * as Device from "expo-device"; -import * as FileSystem from "expo-file-system"; - const Notifications = !Platform.isTV ? require("expo-notifications") : null; -import { getLocales } from "expo-localization"; -import { router, Stack, useSegments } from "expo-router"; -import * as SplashScreen from "expo-splash-screen"; - -import * as TaskManager from "expo-task-manager"; -import { Provider as JotaiProvider } from "jotai"; -import { useEffect, useRef, useState } from "react"; -import { I18nextProvider } from "react-i18next"; -import { Appearance, AppState } from "react-native"; -import { SystemBars } from "react-native-edge-to-edge"; -import { GestureHandlerRootView } from "react-native-gesture-handler"; -import * as ScreenOrientation from "@/packages/expo-screen-orientation"; -import "react-native-reanimated"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; +import { getLocales } from "expo-localization"; import type { EventSubscription } from "expo-modules-core"; +import { getDevicePushTokenAsync } from "expo-notifications"; import type { Notification, NotificationResponse, } from "expo-notifications/build/Notifications.types"; -import type { ExpoPushToken } from "expo-notifications/build/Tokens.types"; -import { useAtom } from "jotai"; -import { Toaster } from "sonner-native"; +import type { DevicePushToken } from "expo-notifications/build/Tokens.types"; +import { router, Stack, useSegments } from "expo-router"; +import * as SplashScreen from "expo-splash-screen"; +import * as TaskManager from "expo-task-manager"; +import { Provider as JotaiProvider, useAtom } from "jotai"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { I18nextProvider } from "react-i18next"; +import { Appearance } from "react-native"; +import { SystemBars } from "react-native-edge-to-edge"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import { userAtom } from "@/providers/JellyfinProvider"; import { store } from "@/utils/store"; +import "react-native-reanimated"; +import { Toaster } from "sonner-native"; if (!Platform.isTV) { Notifications.setNotificationHandler({ @@ -131,24 +122,7 @@ if (!Platform.isTV) { TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { console.log("TaskManager ~ trigger"); - - const settingsData = storage.getString("settings"); - - if (!settingsData) return BackgroundTask.BackgroundTaskResult.Failed; - - const settings: Partial = JSON.parse(settingsData); - - if (!settings?.autoDownload) - return BackgroundTask.BackgroundTaskResult.Failed; - - const token = getTokenFromStorage(); - const deviceId = getOrSetDeviceId(); - const baseDirectory = FileSystem.documentDirectory; - - if (!token || !deviceId || !baseDirectory) - return BackgroundTask.BackgroundTaskResult.Failed; - - // Be sure to return the successful result type! + // Background fetch task placeholder - currently unused return BackgroundTask.BackgroundTaskResult.Success; }); } @@ -213,11 +187,7 @@ export default function RootLayout() { const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 0, - refetchOnMount: true, - refetchOnReconnect: true, - refetchOnWindowFocus: true, - retryOnMount: true, + staleTime: 30000, }, }, }); @@ -226,8 +196,7 @@ function Layout() { const { settings } = useSettings(); const [user] = useAtom(userAtom); const [api] = useAtom(apiAtom); - const appState = useRef(AppState.currentState); - const segments = useSegments(); + const _segments = useSegments(); useEffect(() => { i18n.changeLanguage( @@ -237,26 +206,26 @@ function Layout() { useNotificationObserver(); - const [expoPushToken, setExpoPushToken] = useState(); + const [pushToken, setPushToken] = useState(); const notificationListener = useRef(null); const responseListener = useRef(null); useEffect(() => { - if (!Platform.isTV && expoPushToken && api && user) { + if (!Platform.isTV && pushToken && api && user) { api ?.post("/Streamyfin/device", { - token: expoPushToken.data, + token: pushToken.data, deviceId: getOrSetDeviceId(), userId: user.Id, }) - .then((_) => console.log("Posted expo push token")) + .then((_) => console.log("Posted device push token")) .catch((_) => - writeErrorLog("Failed to push expo push token to plugin"), + writeErrorLog("Failed to push device push token to plugin"), ); } else console.log("No token available"); - }, [api, expoPushToken, user]); + }, [api, pushToken, user]); - async function registerNotifications() { + const registerNotifications = useCallback(async () => { if (Platform.OS === "android") { console.log("Setting android notification channel 'default'"); await Notifications?.setNotificationChannelAsync("default", { @@ -287,13 +256,11 @@ function Layout() { // only create push token for real devices (pointless for emulators) if (Device.isDevice) { - Notifications?.getExpoPushTokenAsync({ - projectId: "streamyfin-4fec1", - }) - .then((token: ExpoPushToken) => token && setExpoPushToken(token)) + getDevicePushTokenAsync() + .then((token: DevicePushToken) => token && setPushToken(token)) .catch((reason: any) => console.log("Failed to get token", reason)); } - } + }, [user]); useEffect(() => { if (!Platform.isTV) { @@ -357,61 +324,7 @@ function Layout() { responseListener.current?.remove(); }; } - }, [user, api]); - - useEffect(() => { - if (Platform.isTV) { - return; - } - - if (segments.includes("direct-player" as never)) { - if ( - !settings.followDeviceOrientation && - settings.defaultVideoOrientation - ) { - ScreenOrientation.lockAsync(settings.defaultVideoOrientation); - } - return; - } - - if (settings.followDeviceOrientation === true) { - ScreenOrientation.unlockAsync(); - } else { - ScreenOrientation.lockAsync( - ScreenOrientation.OrientationLock.PORTRAIT_UP, - ); - } - }, [ - settings.followDeviceOrientation, - settings.defaultVideoOrientation, - segments, - ]); - - useEffect(() => { - if (Platform.isTV) { - return; - } - - const subscription = AppState.addEventListener("change", (nextAppState) => { - if ( - appState.current.match(/inactive|background/) && - nextAppState === "active" - ) { - BackGroundDownloader.checkForExistingDownloads().catch( - (error: unknown) => { - writeErrorLog("Failed to resume background downloads", error); - }, - ); - } - }); - - BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => { - writeErrorLog("Failed to resume background downloads", error); - }); - return () => { - subscription.remove(); - }; - }, []); + }, [user]); return ( @@ -420,52 +333,55 @@ function Layout() { - - + + + + diff --git a/app/login.tsx b/app/login.tsx index d64516b6..d941cece 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -4,17 +4,16 @@ import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { t } from "i18next"; import { useAtomValue } from "jotai"; -import type React from "react"; import { useCallback, useEffect, useState } from "react"; import { Alert, Keyboard, KeyboardAvoidingView, Platform, - SafeAreaView, TouchableOpacity, View, } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; import { z } from "zod"; import { Button } from "@/components/Button"; import { Input } from "@/components/common/Input"; @@ -63,12 +62,13 @@ const Login: React.FC = () => { address: _apiUrl, }); + // Wait for server setup and state updates to complete setTimeout(() => { if (_username && _password) { setCredentials({ username: _username, password: _password }); login(_username, _password); } - }, 300); + }, 0); } })(); }, [_apiUrl, _username, _password]); @@ -82,10 +82,10 @@ const Login: React.FC = () => { onPress={() => { removeServer(); }} - className='flex flex-row items-center' + className='flex flex-row items-center pr-2 pl-1' > - + {t("login.change_server")} @@ -371,10 +371,11 @@ const Login: React.FC = () => { // Mobile layout {api?.basePath ? ( - + @@ -443,7 +444,7 @@ const Login: React.FC = () => { ) : ( - + (key: string): T | undefined { +(storage as any).get = function (key: string): T | undefined { try { const serializedItem = this.getString(key); if (!serializedItem) return undefined; @@ -20,10 +20,10 @@ MMKV.prototype.get = function (key: string): T | undefined { } }; -MMKV.prototype.setAny = function (key: string, value: any | undefined): void { +(storage as any).setAny = function (key: string, value: any | undefined): void { try { if (value === undefined) { - this.delete(key); + this.remove(key); } else { this.set(key, JSON.stringify(value)); } diff --git a/babel.config.js b/babel.config.js index 41dc7e41..012913ae 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,6 +2,6 @@ module.exports = (api) => { api.cache(true); return { presets: ["babel-preset-expo"], - plugins: ["nativewind/babel", "react-native-reanimated/plugin"], + plugins: ["nativewind/babel", "react-native-worklets/plugin"], }; }; diff --git a/bun.lock b/bun.lock index 42385190..76012d57 100644 --- a/bun.lock +++ b/bun.lock @@ -4,102 +4,109 @@ "": { "name": "streamyfin", "dependencies": { - "@bottom-tabs/react-navigation": "^0.12.0", - "@expo/metro-runtime": "~5.0.5", + "@bottom-tabs/react-navigation": "^1.0.2", + "@expo/metro-runtime": "~6.1.1", "@expo/react-native-action-sheet": "^4.1.1", - "@expo/vector-icons": "^14.1.0", + "@expo/ui": "^0.2.0-beta.4", + "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "^5.1.0", - "@jellyfin/sdk": "^0.11.0", - "@kesha-antonov/react-native-background-downloader": "^3.2.6", + "@jellyfin/sdk": "^0.13.0", "@react-native-community/netinfo": "^11.4.1", - "@react-native-menu/menu": "1.2.3", "@react-navigation/material-top-tabs": "^7.2.14", "@react-navigation/native": "^7.0.14", - "@shopify/flash-list": "^1.8.3", + "@shopify/flash-list": "2.0.2", "@tanstack/react-query": "^5.66.0", "axios": "^1.7.9", - "expo": "^53.0.23", - "expo-application": "~6.1.4", - "expo-asset": "~11.1.7", - "expo-background-task": "~0.2.8", - "expo-blur": "~14.1.4", - "expo-brightness": "~13.1.4", - "expo-build-properties": "~0.14.6", - "expo-constants": "~17.1.5", - "expo-device": "~7.1.4", - "expo-font": "~13.3.1", - "expo-haptics": "~14.1.4", - "expo-image": "~2.4.0", - "expo-linear-gradient": "~14.1.4", - "expo-linking": "~7.1.4", - "expo-localization": "~16.1.5", - "expo-notifications": "~0.31.2", - "expo-router": "~5.1.7", - "expo-screen-orientation": "~8.1.6", - "expo-sensors": "~14.1.4", - "expo-sharing": "~13.1.5", - "expo-splash-screen": "~0.30.8", - "expo-status-bar": "~2.2.3", - "expo-system-ui": "~5.0.11", - "expo-task-manager": "~13.1.6", - "expo-web-browser": "~14.2.0", + "expo": "^54.0.23", + "expo-application": "~7.0.5", + "expo-asset": "~12.0.6", + "expo-background-task": "~1.0.5", + "expo-blur": "~15.0.5", + "expo-brightness": "~14.0.5", + "expo-build-properties": "~1.0.6", + "expo-constants": "~18.0.10", + "expo-dev-client": "~6.0.17", + "expo-device": "~8.0.5", + "expo-font": "~14.0.9", + "expo-haptics": "~15.0.5", + "expo-image": "~3.0.10", + "expo-linear-gradient": "~15.0.5", + "expo-linking": "~8.0.6", + "expo-localization": "~17.0.5", + "expo-notifications": "~0.32.7", + "expo-router": "~6.0.14", + "expo-screen-orientation": "~9.0.5", + "expo-sensors": "~15.0.5", + "expo-sharing": "~14.0.5", + "expo-splash-screen": "~31.0.7", + "expo-status-bar": "~3.0.6", + "expo-system-ui": "~6.0.8", + "expo-task-manager": "~14.0.8", + "expo-web-browser": "~15.0.9", "i18next": "^25.0.0", "jotai": "^2.12.5", "lodash": "^4.17.21", "nativewind": "^2.0.11", - "react": "19.0.0", - "react-dom": "19.0.0", + "patch-package": "^8.0.0", + "react": "19.1.0", + "react-dom": "19.1.0", "react-i18next": "^15.4.0", - "react-native": "npm:react-native-tvos@0.79.5-0", + "react-native": "npm:react-native-tvos@0.81.5-1", "react-native-awesome-slider": "^2.9.0", - "react-native-bottom-tabs": "^0.12.0", + "react-native-bottom-tabs": "^1.0.2", "react-native-circular-progress": "^1.4.1", "react-native-collapsible": "^1.6.2", "react-native-country-flag": "^2.0.2", "react-native-device-info": "^14.0.4", - "react-native-gesture-handler": "~2.24.0", - "react-native-google-cast": "^4.9.0", + "react-native-edge-to-edge": "^1.7.0", + "react-native-gesture-handler": "~2.28.0", + "react-native-google-cast": "^4.9.1", "react-native-image-colors": "^2.4.0", - "react-native-ios-context-menu": "^3.1.0", - "react-native-ios-utilities": "5.1.8", - "react-native-mmkv": "2.12.2", + "react-native-ios-context-menu": "^3.2.1", + "react-native-ios-utilities": "5.2.0", + "react-native-mmkv": "4.0.0", + "react-native-nitro-modules": "^0.31.5", "react-native-pager-view": "^6.9.1", - "react-native-reanimated": "~3.19.1", + "react-native-reanimated": "~4.1.1", "react-native-reanimated-carousel": "4.0.2", - "react-native-safe-area-context": "5.4.0", - "react-native-screens": "~4.11.1", - "react-native-svg": "15.11.2", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.18.0", + "react-native-svg": "15.12.1", "react-native-udp": "^4.1.7", "react-native-url-polyfill": "^2.0.0", "react-native-uuid": "^2.0.3", - "react-native-video": "6.14.1", + "react-native-video": "6.16.1", "react-native-volume-manager": "^2.0.8", - "react-native-web": "^0.20.0", + "react-native-web": "^0.21.0", + "react-native-worklets": "0.5.1", "sonner-native": "^0.21.0", "tailwindcss": "3.3.2", "use-debounce": "^10.0.4", - "zeego": "^3.0.6", "zod": "^4.1.3", }, "devDependencies": { - "@babel/core": "7.28.5", - "@biomejs/biome": "2.2.7", - "@react-native-community/cli": "20.0.2", - "@react-native-tvos/config-tv": "0.1.4", - "@types/jest": "30.0.0", - "@types/lodash": "4.17.20", - "@types/react": "~19.0.10", - "@types/react-test-renderer": "19.1.0", - "cross-env": "10.1.0", - "expo-dev-client": "5.2.4", - "expo-doctor": "1.17.11", - "husky": "9.1.7", - "lint-staged": "16.2.6", + "@babel/core": "^7.20.0", + "@biomejs/biome": "^2.2.4", + "@react-native-community/cli": "^20.0.0", + "@react-native-tvos/config-tv": "^0.1.1", + "@types/jest": "^29.5.12", + "@types/lodash": "^4.17.15", + "@types/react": "~19.1.10", + "@types/react-test-renderer": "^19.0.0", + "cross-env": "^10.0.0", + "expo-doctor": "^1.17.0", + "husky": "^9.1.7", + "lint-staged": "^16.1.5", + "postinstall-postinstall": "^2.1.0", "react-test-renderer": "19.1.1", - "typescript": "5.8.3", + "typescript": "~5.9.2", }, }, }, + "overrides": { + "expo-constants": "~18.0.10", + "expo-task-manager": "~14.0.8", + }, "packages": { "@0no-co/graphql.web": ["@0no-co/graphql.web@1.2.0", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw=="], @@ -211,6 +218,8 @@ "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA=="], + "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.28.3", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.3", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg=="], + "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA=="], "@babel/plugin-transform-computed-properties": ["@babel/plugin-transform-computed-properties@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/template": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw=="], @@ -291,25 +300,25 @@ "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], - "@biomejs/biome": ["@biomejs/biome@2.2.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.7", "@biomejs/cli-darwin-x64": "2.2.7", "@biomejs/cli-linux-arm64": "2.2.7", "@biomejs/cli-linux-arm64-musl": "2.2.7", "@biomejs/cli-linux-x64": "2.2.7", "@biomejs/cli-linux-x64-musl": "2.2.7", "@biomejs/cli-win32-arm64": "2.2.7", "@biomejs/cli-win32-x64": "2.2.7" }, "bin": { "biome": "bin/biome" } }, "sha512-1a8j0UP1vXVUf3UzMZEJ/zS2VgAG6wU6Cuh/I764sUGI+MCnJs/9WaojHYBDCxCMLTgU60/WqnYof85emXmSBA=="], + "@biomejs/biome": ["@biomejs/biome@2.3.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.4", "@biomejs/cli-darwin-x64": "2.3.4", "@biomejs/cli-linux-arm64": "2.3.4", "@biomejs/cli-linux-arm64-musl": "2.3.4", "@biomejs/cli-linux-x64": "2.3.4", "@biomejs/cli-linux-x64-musl": "2.3.4", "@biomejs/cli-win32-arm64": "2.3.4", "@biomejs/cli-win32-x64": "2.3.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TU08LXjBHdy0mEY9APtEtZdNQQijXUDSXR7IK1i45wgoPD5R0muK7s61QcFir6FpOj/RP1+YkPx5QJlycXUU3w=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xBUUsebnO2/Qj1v7eZmKUy2ZcFkZ4/jLUkxN02Qup1RPoRaiW9AKXHrqS3L7iX6PzofHY2xuZ+Pb9kAcpoe0qA=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w40GvlNzLaqmuWYiDU6Ys9FNhJiclngKqcGld3iJIiy2bpJ0Q+8n3haiaC81uTPY/NA0d8Q/I3Z9+ajc14102Q=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-vsY4NhmxqgfLJufr9XUnC+yGUPJiXAc1mz6FcjaAmuIuLwfghN4uQO7hnW2AneGyoi2mNe9Jbvf6Qtq4AjzrFg=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-3s7TLVtjJ7ni1xADXsS7x7GMUrLBZXg8SemXc3T0XLslzvqKj/dq1xGeBQ+pOWQzng9MaozfacIHdK2UlJ3jGA=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-nUdco104rjV9dULi1VssQ5R/kX2jE/Z2sDjyqS+siV9sTQda0DwmEUixFNRCWvZJRRiZUWhgiDFJ4n7RowO8Mg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-y7efHyyM2gYmHy/AdWEip+VgTMe9973aP7XYKPzu/j8JxnPHuSUXftzmPhkVw0lfm4ECGbdBdGD6+rLmTgNZaA=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-FrTwvKO/7t5HbVTvhlMOTOVQLAcR7r4O4iFQhEpZXUtBfosHqrX/JJlX7daPawoe14MDcCu9CDg0zLVpTuDvuQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-IruVGQRwMURivWazchiq7gKAqZSFs5so6gi0hJyxk7x6HR+iwZbO2IxNOqyLURBvL06qkIHs7Wffl6Bw30vCbQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-tPTcGAIEOOZrj2tQ7fdraWlaxNKApBw6l4In8wQQV1IyxnAexqi0hykHzKEX8hKKctf5gxGBfNCzyIvqpj4CFQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gKfjWR/6/dfIxPJCw8REdEowiXCkIpl9jycpNVHux8aX2yhWPLjydOshkDL6Y/82PcQJHn95VCj7J+BRcE5o1Q=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-MnsysF5s/iLC5wnYvuMseOy+m8Pd4bWG1uwlVyy2AUbfjAVUgtbYbboc5wMXljFrDY7e6rLjLTR4S2xqDpGlQg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-mzKFFv/w66e4/jCobFmD3kymCqG+FuWE7sVa4Yjqd9v7qt2UhXo67MSZKY9Ih18V2IwPzRKQPCw6KwdZs6AXSA=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-h5D1jhwA2b7cFXerYiJfXHSzzAMFFoEDL5Mc2BgiaEw0iaSgSso/3Nc6FbOR55aTQISql+IpB4PS7JoV26Gdbw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-5TJ6JfVez+yyupJ/iGUici2wzKf0RrSAxJhghQXtAEsc67OIpdwSKAQboemILrwKfHDi5s6mu7mX+VTCTUydkw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-URqAJi0kONyBKG4V9NVafHLDtm6IHmF4qPYi/b6x7MD6jxpWeJiTCO6R5+xDlWckX2T/OGv6Yq3nkz6s0M8Ykw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-FGCijXecmC4IedQ0esdYNlMpx0Jxgf4zceCaMu6fkjWyjgn50ZQtMiqZZQ0Q/77yqPxvtkgZAvt5uGw0gAAjig=="], - "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.12.2", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-vQ/7pNcWk2TgveVCBfdQnbLC6zaRbIL2EM4Vsifk/NCCZ49oT1G2K7iXAsCPby9/ofd6ndGOMmsVLFVq5M7fjw=="], + "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.0.2", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-OrCw8s2NzFxO1TO5W2vyr7HNvh1Yjy00f72D/0BIPtImc0aj5CRrT9nFRE7YP0FWZb0AY5+0QU9jaoph1rBlSg=="], "@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="], @@ -317,37 +326,43 @@ "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], - "@expo/cli": ["@expo/cli@0.24.22", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@babel/runtime": "^7.20.0", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/devcert": "^1.1.2", "@expo/env": "~1.0.7", "@expo/image-utils": "^0.7.6", "@expo/json-file": "^9.1.5", "@expo/metro-config": "~0.20.17", "@expo/osascript": "^2.2.5", "@expo/package-manager": "^1.8.6", "@expo/plist": "^0.3.5", "@expo/prebuild-config": "^9.0.12", "@expo/schema-utils": "^0.1.0", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.79.6", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.4.3", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "bin": { "expo-internal": "build/bin/cli" } }, "sha512-cEg6/F8ZWjoVkEwm0rXKReWbsCUROFbLFBYht+d5RzHnDwJoTX4QWJKx4m+TGNDPamRUIGw36U4z41Fvev0XmA=="], + "@expo/cli": ["@expo/cli@54.0.16", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devcert": "^1.1.2", "@expo/env": "~2.0.7", "@expo/image-utils": "^0.8.7", "@expo/json-file": "^10.0.7", "@expo/mcp-tunnel": "~0.1.0", "@expo/metro": "~54.1.0", "@expo/metro-config": "~54.0.9", "@expo/osascript": "^2.3.7", "@expo/package-manager": "^1.9.8", "@expo/plist": "^0.4.7", "@expo/prebuild-config": "^54.0.6", "@expo/schema-utils": "^0.1.7", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.81.5", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "expo-server": "^1.0.4", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^7.4.3", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "build/bin/cli" } }, "sha512-hY/OdRaJMs5WsVPuVSZ+RLH3VObJmL/pv5CGCHEZHN2PxZjSZSdctyKV8UcFBXTF0yIKNAJ9XLs1dlNYXHh4Cw=="], "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.5", "", { "dependencies": { "node-forge": "^1.2.1", "nullthrows": "^1.1.1" } }, "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw=="], - "@expo/config": ["@expo/config@11.0.13", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~10.1.2", "@expo/config-types": "^53.0.5", "@expo/json-file": "^9.1.5", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^10.4.2", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", "sucrase": "3.35.0" } }, "sha512-TnGb4u/zUZetpav9sx/3fWK71oCPaOjZHoVED9NaEncktAd0Eonhq5NUghiJmkUGt3gGSjRAEBXiBbbY9/B1LA=="], + "@expo/config": ["@expo/config@12.0.10", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "@expo/config-plugins": "~54.0.2", "@expo/config-types": "^54.0.8", "@expo/json-file": "^10.0.7", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^10.4.2", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", "sucrase": "3.35.0" } }, "sha512-lJMof5Nqakq1DxGYlghYB/ogSBjmv4Fxn1ovyDmcjlRsQdFCXgu06gEUogkhPtc9wBt9WlTTfqENln5HHyLW6w=="], - "@expo/config-plugins": ["@expo/config-plugins@10.1.2", "", { "dependencies": { "@expo/config-types": "^53.0.5", "@expo/json-file": "~9.1.5", "@expo/plist": "^0.3.5", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^10.4.2", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-IMYCxBOcnuFStuK0Ay+FzEIBKrwW8OVUMc65+v0+i7YFIIe8aL342l7T4F8lR4oCfhXn7d6M5QPgXvjtc/gAcw=="], + "@expo/config-plugins": ["@expo/config-plugins@54.0.2", "", { "dependencies": { "@expo/config-types": "^54.0.8", "@expo/json-file": "~10.0.7", "@expo/plist": "^0.4.7", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^10.4.2", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-jD4qxFcURQUVsUFGMcbo63a/AnviK8WUGard+yrdQE3ZrB/aurn68SlApjirQQLEizhjI5Ar2ufqflOBlNpyPg=="], - "@expo/config-types": ["@expo/config-types@53.0.5", "", {}, "sha512-kqZ0w44E+HEGBjy+Lpyn0BVL5UANg/tmNixxaRMLS6nf37YsDrLk2VMAmeKMMk5CKG0NmOdVv3ngeUjRQMsy9g=="], + "@expo/config-types": ["@expo/config-types@54.0.8", "", {}, "sha512-lyIn/x/Yz0SgHL7IGWtgTLg6TJWC9vL7489++0hzCHZ4iGjVcfZmPTUfiragZ3HycFFj899qN0jlhl49IHa94A=="], "@expo/devcert": ["@expo/devcert@1.2.0", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0", "glob": "^10.4.2" } }, "sha512-Uilcv3xGELD5t/b0eM4cxBFEKQRIivB3v7i+VhWLV/gL98aw810unLKKJbGAxAIhY6Ipyz8ChWibFsKFXYwstA=="], - "@expo/env": ["@expo/env@1.0.7", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0" } }, "sha512-qSTEnwvuYJ3umapO9XJtrb1fAqiPlmUUg78N0IZXXGwQRt+bkp0OBls+Y5Mxw/Owj8waAM0Z3huKKskRADR5ow=="], + "@expo/devtools": ["@expo/devtools@0.1.7", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-dfIa9qMyXN+0RfU6SN4rKeXZyzKWsnz6xBSDccjL4IRiE+fQ0t84zg0yxgN4t/WK2JU5v6v4fby7W7Crv9gJvA=="], - "@expo/fingerprint": ["@expo/fingerprint@0.13.4", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "find-up": "^5.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", "ignore": "^5.3.1", "minimatch": "^9.0.0", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-MYfPYBTMfrrNr07DALuLhG6EaLVNVrY/PXjEzsjWdWE4ZFn0yqI0IdHNkJG7t1gePT8iztHc7qnsx+oo/rDo6w=="], + "@expo/env": ["@expo/env@2.0.7", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0" } }, "sha512-BNETbLEohk3HQ2LxwwezpG8pq+h7Fs7/vAMP3eAtFT1BCpprLYoBBFZH7gW4aqGfqOcVP4Lc91j014verrYNGg=="], - "@expo/image-utils": ["@expo/image-utils@0.7.6", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0", "temp-dir": "~2.0.0", "unique-string": "~2.0.0" } }, "sha512-GKnMqC79+mo/1AFrmAcUcGfbsXXTRqOMNS1umebuevl3aaw+ztsYEFEiuNhHZW7PQ3Xs3URNT513ZxKhznDscw=="], + "@expo/fingerprint": ["@expo/fingerprint@0.15.3", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^10.4.2", "ignore": "^5.3.1", "minimatch": "^9.0.0", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-8YPJpEYlmV171fi+t+cSLMX1nC5ngY9j2FiN70dHldLpd6Ct6ouGhk96svJ4BQZwsqwII2pokwzrDAwqo4Z0FQ=="], - "@expo/json-file": ["@expo/json-file@9.1.5", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3" } }, "sha512-prWBhLUlmcQtvN6Y7BpW2k9zXGd3ySa3R6rAguMJkp1z22nunLN64KYTUWfijFlprFoxm9r2VNnGkcbndAlgKA=="], + "@expo/image-utils": ["@expo/image-utils@0.8.7", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", "resolve-global": "^1.0.0", "semver": "^7.6.0", "temp-dir": "~2.0.0", "unique-string": "~2.0.0" } }, "sha512-SXOww4Wq3RVXLyOaXiCCuQFguCDh8mmaHBv54h/R29wGl4jRY8GEyQEx8SypV/iHt1FbzsU/X3Qbcd9afm2W2w=="], - "@expo/metro-config": ["@expo/metro-config@0.20.17", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@babel/parser": "^7.20.0", "@babel/types": "^7.20.0", "@expo/config": "~11.0.12", "@expo/env": "~1.0.7", "@expo/json-file": "~9.1.5", "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "debug": "^4.3.2", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", "glob": "^10.4.2", "jsc-safe-url": "^0.2.4", "lightningcss": "~1.27.0", "minimatch": "^9.0.0", "postcss": "~8.4.32", "resolve-from": "^5.0.0" } }, "sha512-lpntF2UZn5bTwrPK6guUv00Xv3X9mkN3YYla+IhEHiYXWyG7WKOtDU0U4KR8h3ubkZ6SPH3snDyRyAzMsWtZFA=="], + "@expo/json-file": ["@expo/json-file@10.0.7", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3" } }, "sha512-z2OTC0XNO6riZu98EjdNHC05l51ySeTto6GP7oSQrCvQgG9ARBwD1YvMQaVZ9wU7p/4LzSf1O7tckL3B45fPpw=="], - "@expo/metro-runtime": ["@expo/metro-runtime@5.0.5", "", { "peerDependencies": { "react-native": "*" } }, "sha512-P8UFTi+YsmiD1BmdTdiIQITzDMcZgronsA3RTQ4QKJjHM3bas11oGzLQOnFaIZnlEV8Rrr3m1m+RHxvnpL+t/A=="], + "@expo/mcp-tunnel": ["@expo/mcp-tunnel@0.1.0", "", { "dependencies": { "ws": "^8.18.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.6" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.13.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-rJ6hl0GnIZj9+ssaJvFsC7fwyrmndcGz+RGFzu+0gnlm78X01957yjtHgjcmnQAgL5hWEOR6pkT0ijY5nU5AWw=="], + + "@expo/metro": ["@expo/metro@54.1.0", "", { "dependencies": { "metro": "0.83.2", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2" } }, "sha512-MgdeRNT/LH0v1wcO0TZp9Qn8zEF0X2ACI0wliPtv5kXVbXWI+yK9GyrstwLAiTXlULKVIg3HVSCCvmLu0M3tnw=="], + + "@expo/metro-config": ["@expo/metro-config@54.0.9", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~12.0.10", "@expo/env": "~2.0.7", "@expo/json-file": "~10.0.7", "@expo/metro": "~54.1.0", "@expo/spawn-async": "^1.7.2", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", "glob": "^10.4.2", "hermes-parser": "^0.29.1", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "minimatch": "^9.0.0", "postcss": "~8.4.32", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-CRI4WgFXrQ2Owyr8q0liEBJveUIF9DcYAKadMRsJV7NxGNBdrIIKzKvqreDfsGiRqivbLsw6UoNb3UE7/SvPfg=="], + + "@expo/metro-runtime": ["@expo/metro-runtime@6.1.2", "", { "dependencies": { "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g=="], "@expo/osascript": ["@expo/osascript@2.3.7", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "exec-async": "^2.2.0" } }, "sha512-IClSOXxR0YUFxIriUJVqyYki7lLMIHrrzOaP01yxAL1G8pj2DWV5eW1y5jSzIcIfSCNhtGsshGd1tU/AYup5iQ=="], "@expo/package-manager": ["@expo/package-manager@1.9.8", "", { "dependencies": { "@expo/json-file": "^10.0.7", "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, "sha512-4/I6OWquKXYnzo38pkISHCOCOXxfeEmu4uDoERq1Ei/9Ur/s9y3kLbAamEkitUkDC7gHk1INxRWEfFNzGbmOrA=="], - "@expo/plist": ["@expo/plist@0.3.5", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", "xmlbuilder": "^15.1.1" } }, "sha512-9RYVU1iGyCJ7vWfg3e7c/NVyMFs8wbl+dMWZphtFtsqyN9zppGREU3ctlD3i8KUE0sCUTVnLjCWr+VeUIDep2g=="], + "@expo/plist": ["@expo/plist@0.4.7", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", "xmlbuilder": "^15.1.1" } }, "sha512-dGxqHPvCZKeRKDU1sJZMmuyVtcASuSYh1LPFVaM1DuffqPL36n6FMEL0iUqq2Tx3xhWk8wCnWl34IKplUjJDdA=="], - "@expo/prebuild-config": ["@expo/prebuild-config@9.0.12", "", { "dependencies": { "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/config-types": "^53.0.5", "@expo/image-utils": "^0.7.6", "@expo/json-file": "^9.1.5", "@react-native/normalize-colors": "0.79.6", "debug": "^4.3.1", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" } }, "sha512-AKH5Scf+gEMgGxZZaimrJI2wlUJlRoqzDNn7/rkhZa5gUTnO4l6slKak2YdaH+nXlOWCNfAQWa76NnpQIfmv6Q=="], + "@expo/prebuild-config": ["@expo/prebuild-config@54.0.6", "", { "dependencies": { "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/config-types": "^54.0.8", "@expo/image-utils": "^0.8.7", "@expo/json-file": "^10.0.7", "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.1", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-xowuMmyPNy+WTNq+YX0m0EFO/Knc68swjThk4dKivgZa8zI1UjvFXOBIOp8RX4ljCXLzwxQJM5oBBTvyn+59ZA=="], "@expo/react-native-action-sheet": ["@expo/react-native-action-sheet@4.1.1", "", { "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "hoist-non-react-statics": "^3.3.0" }, "peerDependencies": { "react": ">=18.0.0" } }, "sha512-4KRaba2vhqDRR7ObBj6nrD5uJw8ePoNHdIOMETTpgGTX7StUbrF4j/sfrP1YUyaPEa1P8FXdwG6pB+2WtrJd1A=="], @@ -355,26 +370,18 @@ "@expo/sdk-runtime-versions": ["@expo/sdk-runtime-versions@1.0.0", "", {}, "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ=="], - "@expo/server": ["@expo/server@0.6.3", "", { "dependencies": { "abort-controller": "^3.0.0", "debug": "^4.3.4", "source-map-support": "~0.5.21", "undici": "^6.18.2 || ^7.0.0" } }, "sha512-Ea7NJn9Xk1fe4YeJ86rObHSv/bm3u/6WiQPXEqXJ2GrfYpVab2Swoh9/PnSM3KjR64JAgKjArDn1HiPjITCfHA=="], - "@expo/spawn-async": ["@expo/spawn-async@1.7.2", "", { "dependencies": { "cross-spawn": "^7.0.3" } }, "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew=="], "@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="], - "@expo/vector-icons": ["@expo/vector-icons@14.1.0", "", { "peerDependencies": { "expo-font": "*", "react": "*", "react-native": "*" } }, "sha512-7T09UE9h8QDTsUeMGymB4i+iqvtEeaO5VvUjryFB4tugDTG/bkzViWA74hm5pfjjDEhYMXWaX112mcvhccmIwQ=="], + "@expo/ui": ["@expo/ui@0.2.0-canary-20251031-b135dff", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-L/TEKnv/hpQ/Q1sO8lJw0wxdcv88UoA1JShwRSYHLN88UstjxvBNvMqlKGk7SNkTUJtlrttWAundJA4jM2mDPw=="], + + "@expo/vector-icons": ["@expo/vector-icons@15.0.3", "", { "peerDependencies": { "expo-font": ">=14.0.4", "react": "*", "react-native": "*" } }, "sha512-SBUyYKphmlfUBqxSfDdJ3jAdEVSALS2VUPOUyqn48oZmb2TL/O7t7/PQm5v4NQujYEPLPMTLn9KVw6H7twwbTA=="], "@expo/ws-tunnel": ["@expo/ws-tunnel@1.0.6", "", {}, "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q=="], "@expo/xcpretty": ["@expo/xcpretty@4.3.2", "", { "dependencies": { "@babel/code-frame": "7.10.4", "chalk": "^4.1.0", "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw=="], - "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], - - "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], - - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="], - - "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.6", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-vmruJxdiUGDg+ZYcDmS30XDhq/h/+QkINOI5LY/uGjx8cPGwgJW0H6AB902gNTKtccbiKe/rr94EwdmIEz+LAQ=="], "@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="], @@ -395,23 +402,17 @@ "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="], - "@jellyfin/sdk": ["@jellyfin/sdk@0.11.0", "", { "peerDependencies": { "axios": "^1.3.4" } }, "sha512-WmM4as9ptqH+CvC2YsUefNWQDmu2aWIamwAoj7h2BFR6l019pcRFG5FT22egwbdizR6DfdpmsoAWB4x9QCzcEQ=="], + "@jellyfin/sdk": ["@jellyfin/sdk@0.13.0", "", { "peerDependencies": { "axios": "^1.12.0" } }, "sha512-oiBAOXH6s+dKdReSsYgNktBDzbxtg4JVWhEzIxZSxKcWMdSKmBtK41MhXRO7IWAC40DguKUm3nU/Z493qPAlWA=="], "@jest/create-cache-key-function": ["@jest/create-cache-key-function@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3" } }, "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA=="], - "@jest/diff-sequences": ["@jest/diff-sequences@30.0.1", "", {}, "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw=="], - "@jest/environment": ["@jest/environment@29.7.0", "", { "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0" } }, "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw=="], - "@jest/expect-utils": ["@jest/expect-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA=="], + "@jest/expect-utils": ["@jest/expect-utils@29.7.0", "", { "dependencies": { "jest-get-type": "^29.6.3" } }, "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA=="], "@jest/fake-timers": ["@jest/fake-timers@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", "jest-message-util": "^29.7.0", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ=="], - "@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - - "@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], - - "@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="], + "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], "@jest/transform": ["@jest/transform@29.7.0", "", { "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", "@jridgewell/trace-mapping": "^0.3.18", "babel-plugin-istanbul": "^6.1.1", "chalk": "^4.0.0", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.9", "jest-haste-map": "^29.7.0", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "micromatch": "^4.0.4", "pirates": "^4.0.4", "slash": "^3.0.0", "write-file-atomic": "^4.0.2" } }, "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw=="], @@ -449,8 +450,6 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@kesha-antonov/react-native-background-downloader": ["@kesha-antonov/react-native-background-downloader@3.2.6", "", { "peerDependencies": { "react-native": ">=0.57.0" } }, "sha512-J87PHzBh4knWTQNkCNM4LTMZ85RpMW/QSV+0LGdTxz4JmfLXoeg8R6ratbFU0DP/l8K1eL7r4S1Rc8bmqNJ3Ug=="], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -461,32 +460,24 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], - "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], - "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], - "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], - "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], - - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], - "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], @@ -497,6 +488,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], @@ -507,12 +500,6 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], - - "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], - - "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@react-native-community/cli": ["@react-native-community/cli@20.0.2", "", { "dependencies": { "@react-native-community/cli-clean": "20.0.2", "@react-native-community/cli-config": "20.0.2", "@react-native-community/cli-doctor": "20.0.2", "@react-native-community/cli-server-api": "20.0.2", "@react-native-community/cli-tools": "20.0.2", "@react-native-community/cli-types": "20.0.2", "chalk": "^4.1.2", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-ocgRFKRLX8b5rEK38SJfpr0AMl6SqseWljk6c5LxCG/zpCfPPNQdXq1OsDvmEwsqO4OEQ6tmOaSm9OgTm6FhbQ=="], "@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.0.2", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.2", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "sha512-hfbC69fTD0fqZCCep8aqnVztBXUhAckNhi76lEV7USENtgBRwNq2s1wATgKAzOhxKuAL9TEkf5TZ/Dhp/YLhCQ=="], @@ -539,47 +526,45 @@ "@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="], - "@react-native-menu/menu": ["@react-native-menu/menu@1.2.3", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-sEfiVIivsa0lSelFm9Wbm/RAi+XoEHc75GGhjwvSrj9KSCVvNNXwr9F8l42e1t/lzYvVYzmkYxLG6VKxrDYJiw=="], - "@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="], - "@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.79.5-0", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-VvZyBraMdMctuT9sjv1XOirh38PaPQwXjdagfiSnn2Vx5P2X/EHqiqx+UkKZK1AcxUK2dQCI+roXZboRXIB+Bw=="], + "@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.81.5-1", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-v77jJvzH2jzMj3G8pthdaRjiUhmdQ3S/OGiTX45Tn1J+whLaPOEkVRCel9xPHhrTPIEwrOOwGNiAFN/s1hzWZA=="], - "@react-native/assets-registry": ["@react-native/assets-registry@0.79.5", "", {}, "sha512-N4Kt1cKxO5zgM/BLiyzuuDNquZPiIgfktEQ6TqJ/4nKA8zr4e8KJgU6Tb2eleihDO4E24HmkvGc73naybKRz/w=="], + "@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="], - "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.79.6", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.79.6" } }, "sha512-CS5OrgcMPixOyUJ/Sk/HSsKsKgyKT5P7y3CojimOQzWqRZBmoQfxdST4ugj7n1H+ebM2IKqbgovApFbqXsoX0g=="], + "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.81.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.81.5" } }, "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ=="], - "@react-native/babel-preset": ["@react-native/babel-preset@0.79.6", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-arrow-functions": "^7.24.7", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-computed-properties": "^7.24.7", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-function-name": "^7.25.1", "@babel/plugin-transform-literals": "^7.25.2", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-numeric-separator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-shorthand-properties": "^7.24.7", "@babel/plugin-transform-spread": "^7.24.7", "@babel/plugin-transform-sticky-regex": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/template": "^7.25.0", "@react-native/babel-plugin-codegen": "0.79.6", "babel-plugin-syntax-hermes-parser": "0.25.1", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-H+FRO+r2Ql6b5IwfE0E7D52JhkxjeGSBSUpCXAI5zQ60zSBJ54Hwh2bBJOohXWl4J+C7gKYSAd2JHMUETu+c/A=="], + "@react-native/babel-preset": ["@react-native/babel-preset@0.81.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-arrow-functions": "^7.24.7", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-computed-properties": "^7.24.7", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-function-name": "^7.25.1", "@babel/plugin-transform-literals": "^7.25.2", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-numeric-separator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-shorthand-properties": "^7.24.7", "@babel/plugin-transform-spread": "^7.24.7", "@babel/plugin-transform-sticky-regex": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/template": "^7.25.0", "@react-native/babel-plugin-codegen": "0.81.5", "babel-plugin-syntax-hermes-parser": "0.29.1", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA=="], - "@react-native/codegen": ["@react-native/codegen@0.79.5", "", { "dependencies": { "glob": "^7.1.1", "hermes-parser": "0.25.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" }, "peerDependencies": { "@babel/core": "*" } }, "sha512-FO5U1R525A1IFpJjy+KVznEinAgcs3u7IbnbRJUG9IH/MBXi2lEU2LtN+JarJ81MCfW4V2p0pg6t/3RGHFRrlQ=="], + "@react-native/codegen": ["@react-native/codegen@0.81.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.29.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g=="], - "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.79.5", "", { "dependencies": { "@react-native/dev-middleware": "0.79.5", "chalk": "^4.0.0", "debug": "^2.2.0", "invariant": "^2.2.4", "metro": "^0.82.0", "metro-config": "^0.82.0", "metro-core": "^0.82.0", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*" }, "optionalPeers": ["@react-native-community/cli"] }, "sha512-ApLO1ARS8JnQglqS3JAHk0jrvB+zNW3dvNJyXPZPoygBpZVbf8sjvqeBiaEYpn8ETbFWddebC4HoQelDndnrrA=="], + "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.81.5", "", { "dependencies": { "@react-native/dev-middleware": "0.81.5", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.83.1", "metro-config": "^0.83.1", "metro-core": "^0.83.1", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*", "@react-native/metro-config": "*" }, "optionalPeers": ["@react-native-community/cli", "@react-native/metro-config"] }, "sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw=="], - "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.79.6", "", {}, "sha512-lIK/KkaH7ueM22bLO0YNaQwZbT/oeqhaghOvmZacaNVbJR1Cdh/XAqjT8FgCS+7PUnbxA8B55NYNKGZG3O2pYw=="], + "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.81.5", "", {}, "sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w=="], - "@react-native/dev-middleware": ["@react-native/dev-middleware@0.79.6", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.79.6", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^2.2.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^6.2.3" } }, "sha512-BK3GZBa9c7XSNR27EDRtxrgyyA3/mf1j3/y+mPk7Ac0Myu85YNrXnC9g3mL5Ytwo0g58TKrAIgs1fF2Q5Mn6mQ=="], + "@react-native/dev-middleware": ["@react-native/dev-middleware@0.81.5", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.81.5", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^6.2.3" } }, "sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA=="], - "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.79.5", "", {}, "sha512-K3QhfFNKiWKF3HsCZCEoWwJPSMcPJQaeqOmzFP4RL8L3nkpgUwn74PfSCcKHxooVpS6bMvJFQOz7ggUZtNVT+A=="], + "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.81.5", "", {}, "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg=="], - "@react-native/js-polyfills": ["@react-native/js-polyfills@0.79.5", "", {}, "sha512-a2wsFlIhvd9ZqCD5KPRsbCQmbZi6KxhRN++jrqG0FUTEV5vY7MvjjUqDILwJd2ZBZsf7uiDuClCcKqA+EEdbvw=="], + "@react-native/js-polyfills": ["@react-native/js-polyfills@0.81.5", "", {}, "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w=="], - "@react-native/normalize-colors": ["@react-native/normalize-colors@0.79.6", "", {}, "sha512-0v2/ruY7eeKun4BeKu+GcfO+SHBdl0LJn4ZFzTzjHdWES0Cn+ONqKljYaIv8p9MV2Hx/kcdEvbY4lWI34jC/mQ=="], + "@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="], - "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.7.2", "", { "dependencies": { "@react-navigation/elements": "^2.8.0", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-IA1+N9VrZPQiUmNI/T4co/zXyxSZ1m07pbRUBtnZAwqrybNLZh3qsfPIvy+jL89owyGgZEw1UUscHT0EyEhkbQ=="], + "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="], "@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="], - "@react-navigation/elements": ["@react-navigation/elements@2.8.0", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-uvSOkYOF7wfgkt57cl+6fZ2vQgTiYYyJleZzuWthPKHK4nDq2M4sc9SSzgK9GS9UCJFRBErNtl3S+/ErtrwREw=="], + "@react-navigation/elements": ["@react-navigation/elements@2.8.1", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-MLmuS5kPAeAFFOylw89WGjgEFBqGj/KBK6ZrFrAOqLnTqEzk52/SO1olb5GB00k6ZUCDZKJOp1BrLXslxE6TgQ=="], - "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.1", "", { "dependencies": { "@react-navigation/elements": "^2.8.0", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-dox0p78P+dScyRBsCUrSITjG/iXeT/QAj+AS2viBfE2Odr+CmnYbNYawMyAQO/GzYB5ImqP4lgNvP+qBrCypMA=="], + "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.2", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "react-native-tab-view": "^4.2.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-LB/bCDhdaKsexA5w0otgZEDBysGbiCr2l0hW6z41rJQ0JqAOVybH0cBuFr3Awasv0mQh9iTJNha4VsuUb7Q0Xw=="], "@react-navigation/native": ["@react-navigation/native@7.1.19", "", { "dependencies": { "@react-navigation/core": "^7.13.0", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-fM7q8di4Q8sp2WUhiUWOe7bEDRyRhbzsKQOd5N2k+lHeCx3UncsRYuw4Q/KN0EovM3wWKqMMmhy/YWuEO04kgw=="], - "@react-navigation/native-stack": ["@react-navigation/native-stack@7.6.1", "", { "dependencies": { "@react-navigation/elements": "^2.8.0", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-JbYhLzZD6dHv23bGYusToaOlsdEdMgL/QtKEhwV9fEKgFNoDvkZlak8rTPJUrOlC56QwMOPe1vLG83udlNeVYQ=="], + "@react-navigation/native-stack": ["@react-navigation/native-stack@7.6.2", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-CB6chGNLwJYiyOeyCNUKx33yT7XJSwRZIeKHf4S1vs+Oqu3u9zMnvGUIsesNgbgX0xy16gBqYsrWgr0ZczBTtA=="], "@react-navigation/routers": ["@react-navigation/routers@7.5.1", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-pxipMW/iEBSUrjxz2cDD7fNwkqR4xoi0E/PcfTQGCcdJwLoaxzab5kSadBLj1MTJyT0YRrOXL9umHpXtp+Dv4w=="], - "@shopify/flash-list": ["@shopify/flash-list@1.8.3", "", { "dependencies": { "recyclerlistview": "4.2.3", "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-vXuj6JyuMjONVOXjEhWFeaONPuWN/53Cl2LeyeM8TZ0JzUcNU+BE6iyga1/yyJeDf0K7YPgAE/PcUX2+DM1LiA=="], + "@shopify/flash-list": ["@shopify/flash-list@2.0.2", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w=="], "@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="], @@ -587,15 +572,15 @@ "@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="], - "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], + "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], - "@tanstack/query-core": ["@tanstack/query-core@5.90.5", "", {}, "sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.7", "", {}, "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ=="], - "@tanstack/react-query": ["@tanstack/react-query@5.90.5", "", { "dependencies": { "@tanstack/query-core": "5.90.5" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q=="], + "@tanstack/react-query": ["@tanstack/react-query@5.90.7", "", { "dependencies": { "@tanstack/query-core": "5.90.7" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -619,13 +604,13 @@ "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], - "@types/jest": ["@types/jest@30.0.0", "", { "dependencies": { "expect": "^30.0.0", "pretty-format": "^30.0.0" } }, "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA=="], + "@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="], "@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="], - "@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], + "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - "@types/react": ["@types/react@19.0.14", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw=="], + "@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="], "@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="], @@ -635,6 +620,8 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@urql/core": ["@urql/core@5.2.0", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.13", "wonka": "^6.3.2" } }, "sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A=="], "@urql/exchange-retry": ["@urql/exchange-retry@1.3.2", "", { "dependencies": { "@urql/core": "^5.1.2", "wonka": "^6.3.2" } }, "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg=="], @@ -665,6 +652,8 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], + "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], @@ -677,7 +666,7 @@ "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], - "ansi-escapes": ["ansi-escapes@7.1.1", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q=="], + "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], "ansi-fragments": ["ansi-fragments@0.2.1", "", { "dependencies": { "colorette": "^1.0.7", "slice-ansi": "^2.0.0", "strip-ansi": "^5.0.0" } }, "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w=="], @@ -711,7 +700,7 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.13.1", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw=="], + "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], "babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], @@ -725,15 +714,17 @@ "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.5", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg=="], - "babel-plugin-react-native-web": ["babel-plugin-react-native-web@0.19.13", "", {}, "sha512-4hHoto6xaN23LCyZgL9LJZc3olmAxd7b6jDzlZnKXAh4rRAbZRKNBJoOOdp46OBqgy+K0t0guTj5/mhA8inymQ=="], + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], - "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.25.1", "", { "dependencies": { "hermes-parser": "0.25.1" } }, "sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ=="], + "babel-plugin-react-native-web": ["babel-plugin-react-native-web@0.21.2", "", {}, "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA=="], + + "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.29.1", "", { "dependencies": { "hermes-parser": "0.29.1" } }, "sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA=="], "babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="], "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.2.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg=="], - "babel-preset-expo": ["babel-preset-expo@13.2.4", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.79.6", "babel-plugin-react-native-web": "~0.19.13", "babel-plugin-syntax-hermes-parser": "^0.25.1", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4", "react-refresh": "^0.14.2", "resolve-from": "^5.0.0" }, "peerDependencies": { "babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250405" }, "optionalPeers": ["babel-plugin-react-compiler"] }, "sha512-3IKORo3KR+4qtLdCkZNDj8KeA43oBn7RRQejFGWfiZgu/NeaRUSri8YwYjZqybm7hn3nmMv9OLahlvXBX23o5Q=="], + "babel-preset-expo": ["babel-preset-expo@54.0.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.81.5", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.29.1", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4", "resolve-from": "^5.0.0" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo"] }, "sha512-JENWk0bvxW4I1ftveO8GRtX2t2TH6N4Z0TPvIHxroZ/4SswUfyNsUNbbP7Fm4erj3ar/JHGri5kTZ+s3xdjHZw=="], "babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="], @@ -743,7 +734,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.8.25", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA=="], "better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="], @@ -783,10 +774,6 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "caller-callsite": ["caller-callsite@2.0.0", "", { "dependencies": { "callsites": "^2.0.0" } }, "sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ=="], - - "caller-path": ["caller-path@2.0.0", "", { "dependencies": { "caller-callsite": "^2.0.0" } }, "sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A=="], - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], @@ -795,7 +782,7 @@ "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001752", "", {}, "sha512-vKUk7beoukxE47P5gcVNKkDRzXdVofotshHwfR9vmpeFKxmI5PBpgOMC18LUJUA/DvJ70Y7RveasIBraqsyO/g=="], + "caniuse-lite": ["caniuse-lite@1.0.30001754", "", {}, "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -905,12 +892,14 @@ "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], - "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], @@ -931,7 +920,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.244", "", {}, "sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw=="], + "electron-to-chromium": ["electron-to-chromium@1.5.249", "", {}, "sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -943,7 +932,7 @@ "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - "envinfo": ["envinfo@7.19.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-DoSM9VyG6O3vqBf+p3Gjgr/Q52HYBBtO3v+4koAxt1MnWr+zEnxE+nke/yXS4lt2P4SYCHQ4V3f1i88LQVOpAw=="], + "envinfo": ["envinfo@7.20.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-+zUomDcLXsVkQ37vUqWBvQwLaLlj8eZPSi61llaEFAVBY5mhcXdaSw1pSJVl4yTYD5g/gEfpNl28YYk4IPvrrg=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -983,81 +972,83 @@ "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], - "expect": ["expect@30.2.0", "", { "dependencies": { "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.2.0", "jest-message-util": "30.2.0", "jest-mock": "30.2.0", "jest-util": "30.2.0" } }, "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw=="], + "expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="], - "expo": ["expo@53.0.23", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.24.22", "@expo/config": "~11.0.13", "@expo/config-plugins": "~10.1.2", "@expo/fingerprint": "0.13.4", "@expo/metro-config": "0.20.17", "@expo/vector-icons": "^14.0.0", "babel-preset-expo": "~13.2.4", "expo-asset": "~11.1.7", "expo-constants": "~17.1.7", "expo-file-system": "~18.1.11", "expo-font": "~13.3.2", "expo-keep-awake": "~14.1.4", "expo-modules-autolinking": "2.1.14", "expo-modules-core": "2.5.0", "react-native-edge-to-edge": "1.6.0", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-6TOLuNCP3AsSkXBJA5W6U/7wpZUop3Q6BxHMtRD2OOgT7CCPvnYgJdnTzqU+gD1hMfcryD8Ejq9RdHbLduXohg=="], + "expo": ["expo@54.0.23", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.16", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devtools": "0.1.7", "@expo/fingerprint": "0.15.3", "@expo/metro": "~54.1.0", "@expo/metro-config": "54.0.9", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.7", "expo-asset": "~12.0.9", "expo-constants": "~18.0.10", "expo-file-system": "~19.0.17", "expo-font": "~14.0.9", "expo-keep-awake": "~15.0.7", "expo-modules-autolinking": "3.0.21", "expo-modules-core": "3.0.25", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-b4uQoiRwQ6nwqsT2709RS15CWYNGF3eJtyr1KyLw9WuMAK7u4jjofkhRiO0+3o1C2NbV+WooyYTOZGubQQMBaQ=="], - "expo-application": ["expo-application@6.1.5", "", { "peerDependencies": { "expo": "*" } }, "sha512-ToImFmzw8luY043pWFJhh2ZMm4IwxXoHXxNoGdlhD4Ym6+CCmkAvCglg0FK8dMLzAb+/XabmOE7Rbm8KZb6NZg=="], + "expo-application": ["expo-application@7.0.7", "", { "peerDependencies": { "expo": "*" } }, "sha512-Jt1/qqnoDUbZ+bK91+dHaZ1vrPDtRBOltRa681EeedkisqguuEeUx4UHqwVyDK2oHWsK6lO3ojetoA4h8OmNcg=="], - "expo-asset": ["expo-asset@11.1.7", "", { "dependencies": { "@expo/image-utils": "^0.7.6", "expo-constants": "~17.1.7" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-b5P8GpjUh08fRCf6m5XPVAh7ra42cQrHBIMgH2UXP+xsj4Wufl6pLy6jRF5w6U7DranUMbsXm8TOyq4EHy7ADg=="], + "expo-asset": ["expo-asset@12.0.9", "", { "dependencies": { "@expo/image-utils": "^0.8.7", "expo-constants": "~18.0.9" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-vrdRoyhGhBmd0nJcssTSk1Ypx3Mbn/eXaaBCQVkL0MJ8IOZpAObAjfD5CTy8+8RofcHEQdh3wwZVCs7crvfOeg=="], - "expo-background-task": ["expo-background-task@0.2.8", "", { "dependencies": { "expo-task-manager": "~13.1.6" }, "peerDependencies": { "expo": "*" } }, "sha512-dePyskpmyDZeOtbr9vWFh+Nrse0TvF6YitJqnKcd+3P7pDMiDr1V2aT6zHdNOc5iV9vPaDJoH/zdmlarp1uHMQ=="], + "expo-background-task": ["expo-background-task@1.0.8", "", { "dependencies": { "expo-task-manager": "~14.0.7" }, "peerDependencies": { "expo": "*" } }, "sha512-G6WnljBhO0K9j0ntmytF5rZLtYUpwh8n2+hcgmxM1ISPAVVZSPHZhkF9YjBOKpdPWZxmukBgEwejfcGckb8TQQ=="], - "expo-blur": ["expo-blur@14.1.5", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CCLJHxN4eoAl06ESKT3CbMasJ98WsjF9ZQEJnuxtDb9ffrYbZ+g9ru84fukjNUOTtc8A8yXE5z8NgY1l0OMrmQ=="], + "expo-blur": ["expo-blur@15.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-SugQQbQd+zRPy8z2G5qDD4NqhcD7srBF7fN7O7yq6q7ZFK59VWvpDxtMoUkmSfdxgqONsrBN/rLdk00USADrMg=="], - "expo-brightness": ["expo-brightness@13.1.4", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-we7F+CB/jsDGRcJ1WGvyACTZUcEMx/a1TlwTkDHOORPr31leOwmk6+k98G4i0GrhvdsQt91w3WXnrWyTh7IyJg=="], + "expo-brightness": ["expo-brightness@14.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-wccb/NdQEd45UF0lgNEksZt3E8uzlIcxIx1ZqZYWbHyNvcS3LUj5wxB6+ZgKTLeWu4vLQ+oHe+F0QrkC9ojrig=="], - "expo-build-properties": ["expo-build-properties@0.14.8", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-GTFNZc5HaCS9RmCi6HspCe2+isleuOWt2jh7UEKHTDQ9tdvzkIoWc7U6bQO9lH3Mefk4/BcCUZD/utl7b1wdqw=="], + "expo-build-properties": ["expo-build-properties@1.0.9", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-2icttCy3OPTk/GWIFt+vwA+0hup53jnmYb7JKRbvNvrrOrz+WblzpeoiaOleI2dYG/vjwpNO8to8qVyKhYJtrQ=="], - "expo-constants": ["expo-constants@17.1.7", "", { "dependencies": { "@expo/config": "~11.0.12", "@expo/env": "~1.0.7" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-byBjGsJ6T6FrLlhOBxw4EaiMXrZEn/MlUYIj/JAd+FS7ll5X/S4qVRbIimSJtdW47hXMq0zxPfJX6njtA56hHA=="], + "expo-constants": ["expo-constants@18.0.10", "", { "dependencies": { "@expo/config": "~12.0.10", "@expo/env": "~2.0.7" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw=="], - "expo-dev-client": ["expo-dev-client@5.2.4", "", { "dependencies": { "expo-dev-launcher": "5.1.16", "expo-dev-menu": "6.1.14", "expo-dev-menu-interface": "1.10.0", "expo-manifests": "~0.16.6", "expo-updates-interface": "~1.1.0" }, "peerDependencies": { "expo": "*" } }, "sha512-s/N/nK5LPo0QZJpV4aPijxyrzV4O49S3dN8D2fljqrX2WwFZzWwFO6dX1elPbTmddxumdcpczsdUPY+Ms8g43g=="], + "expo-dev-client": ["expo-dev-client@6.0.17", "", { "dependencies": { "expo-dev-launcher": "6.0.17", "expo-dev-menu": "7.0.16", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.8", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-zVilIum3sqXFbhYhPT6TuxR3ddH/IfHL82FiOTqJUiYaTQqun1I6ogSvU1djhY1eXUYhfYIBieQNWMVjXPxMvw=="], - "expo-dev-launcher": ["expo-dev-launcher@5.1.16", "", { "dependencies": { "ajv": "8.11.0", "expo-dev-menu": "6.1.14", "expo-manifests": "~0.16.6", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-tbCske9pvbozaEblyxoyo/97D6od9Ma4yAuyUnXtRET1CKAPKYS+c4fiZ+I3B4qtpZwN3JNFUjG3oateN0y6Hg=="], + "expo-dev-launcher": ["expo-dev-launcher@6.0.17", "", { "dependencies": { "expo-dev-menu": "7.0.16", "expo-manifests": "~1.0.8" }, "peerDependencies": { "expo": "*" } }, "sha512-riLxFXaw6Nvgb27TiQtUvoHkW/zTz0aO7M+qxDBBaEbJMJSFl51KSwOJJBTItVQIE9f9jB8x5L1CfLw81/McZw=="], - "expo-dev-menu": ["expo-dev-menu@6.1.14", "", { "dependencies": { "expo-dev-menu-interface": "1.10.0" }, "peerDependencies": { "expo": "*" } }, "sha512-yonNMg2GHJZtuisVowdl1iQjZfYP85r1D1IO+ar9D9zlrBPBJhq2XEju52jd1rDmDkmDuEhBSbPNhzIcsBNiPg=="], + "expo-dev-menu": ["expo-dev-menu@7.0.16", "", { "dependencies": { "expo-dev-menu-interface": "2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-/kjTjk5tcZV0ixYnV3JyzPXKlMimpBNYaDo4XxBbRFIkTf/vmb/9e1BTR2nALnoa/D3MRwtR43gZYT+W/wfKXw=="], - "expo-dev-menu-interface": ["expo-dev-menu-interface@1.10.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-NxtM/qot5Rh2cY333iOE87dDg1S8CibW+Wu4WdLua3UMjy81pXYzAGCZGNOeY7k9GpNFqDPNDXWyBSlk9r2pBg=="], + "expo-dev-menu-interface": ["expo-dev-menu-interface@2.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw=="], - "expo-device": ["expo-device@7.1.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-HS04IiE1Fy0FRjBLurr9e5A6yj3kbmQB+2jCZvbSGpsjBnCLdSk/LCii4f5VFhPIBWJLyYuN5QqJyEAw6BcS4Q=="], + "expo-device": ["expo-device@8.0.9", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-XqRpaljDNAYZGZzMpC+b9KZfzfydtkwx3pJAp6ODDH+O/5wjAw+mLc5wQMGJCx8/aqVmMsAokec7iebxDPFZDA=="], "expo-doctor": ["expo-doctor@1.17.11", "", { "bin": { "expo-doctor": "build/index.js" } }, "sha512-4eYZPJm4op2aRQWvd6RA6dZt1mVQQe79n7iqqFi6P927K8w2ld8kZ2D7m/4ahjj9/HBW9NS98m4qGomKJFDuPg=="], - "expo-file-system": ["expo-file-system@18.1.11", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-HJw/m0nVOKeqeRjPjGdvm+zBi5/NxcdPf8M8P3G2JFvH5Z8vBWqVDic2O58jnT1OFEy0XXzoH9UqFu7cHg9DTQ=="], + "expo-file-system": ["expo-file-system@19.0.17", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-WwaS01SUFrxBnExn87pg0sCTJjZpf2KAOzfImG0o8yhkU7fbYpihpl/oocXBEsNbj58a8hVt1Y4CVV5c1tzu/g=="], - "expo-font": ["expo-font@13.3.2", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-wUlMdpqURmQ/CNKK/+BIHkDA5nGjMqNlYmW0pJFXY/KE/OG80Qcavdu2sHsL4efAIiNGvYdBS10WztuQYU4X0A=="], + "expo-font": ["expo-font@14.0.9", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg=="], - "expo-haptics": ["expo-haptics@14.1.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-QZdE3NMX74rTuIl82I+n12XGwpDWKb8zfs5EpwsnGi/D/n7O2Jd4tO5ivH+muEG/OCJOMq5aeaVDqqaQOhTkcA=="], + "expo-haptics": ["expo-haptics@15.0.7", "", { "peerDependencies": { "expo": "*" } }, "sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ=="], - "expo-image": ["expo-image@2.4.1", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-yHp0Cy4ylOYyLR21CcH6i70DeRyLRPc0yAIPFPn4BT/BpkJNaX5QMXDppcHa58t4WI3Bb8QRJRLuAQaeCtDF8A=="], + "expo-image": ["expo-image@3.0.10", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-i4qNCEf9Ur7vDqdfDdFfWnNCAF2efDTdahuDy9iELPS2nzMKBLeeGA2KxYEPuRylGCS96Rwm+SOZJu6INc2ADQ=="], "expo-json-utils": ["expo-json-utils@0.15.0", "", {}, "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ=="], - "expo-keep-awake": ["expo-keep-awake@14.1.4", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-wU9qOnosy4+U4z/o4h8W9PjPvcFMfZXrlUoKTMBW7F4pLqhkkP/5G4EviPZixv4XWFMjn1ExQ5rV6BX8GwJsWA=="], + "expo-keep-awake": ["expo-keep-awake@15.0.7", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CgBNcWVPnrIVII5G54QDqoE125l+zmqR4HR8q+MQaCfHet+dYpS5vX5zii/RMayzGN4jPgA4XYIQ28ePKFjHoA=="], - "expo-linear-gradient": ["expo-linear-gradient@14.1.5", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-BSN3MkSGLZoHMduEnAgfhoj3xqcDWaoICgIr4cIYEx1GcHfKMhzA/O4mpZJ/WC27BP1rnAqoKfbclk1eA70ndQ=="], + "expo-linear-gradient": ["expo-linear-gradient@15.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-yF+y+9Shpr/OQFfy/wglB/0bykFMbwHBTuMRa5Of/r2P1wbkcacx8rg0JsUWkXH/rn2i2iWdubyqlxSJa3ggZA=="], - "expo-linking": ["expo-linking@7.1.7", "", { "dependencies": { "expo-constants": "~17.1.7", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ZJaH1RIch2G/M3hx2QJdlrKbYFUTOjVVW4g39hfxrE5bPX9xhZUYXqxqQtzMNl1ylAevw9JkgEfWbBWddbZ3UA=="], + "expo-linking": ["expo-linking@8.0.8", "", { "dependencies": { "expo-constants": "~18.0.8", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg=="], - "expo-localization": ["expo-localization@16.1.6", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-v4HwNzs8QvyKHwl40MvETNEKr77v1o9/eVC8WCBY++DIlBAvonHyJe2R9CfqpZbC4Tlpl7XV+07nLXc8O5PQsA=="], + "expo-localization": ["expo-localization@17.0.7", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-ACg1B0tJLNa+f8mZfAaNrMyNzrrzHAARVH1sHHvh+LolKdQpgSKX69Uroz1Llv4C71furpwBklVStbNcEwVVVA=="], - "expo-manifests": ["expo-manifests@0.16.6", "", { "dependencies": { "@expo/config": "~11.0.12", "expo-json-utils": "~0.15.0" }, "peerDependencies": { "expo": "*" } }, "sha512-1A+do6/mLUWF9xd3uCrlXr9QFDbjbfqAYmUy8UDLOjof1lMrOhyeC4Yi6WexA/A8dhZEpIxSMCKfn7G4aHAh4w=="], + "expo-manifests": ["expo-manifests@1.0.8", "", { "dependencies": { "@expo/config": "~12.0.8", "expo-json-utils": "~0.15.0" }, "peerDependencies": { "expo": "*" } }, "sha512-nA5PwU2uiUd+2nkDWf9e71AuFAtbrb330g/ecvuu52bmaXtN8J8oiilc9BDvAX0gg2fbtOaZdEdjBYopt1jdlQ=="], - "expo-modules-autolinking": ["expo-modules-autolinking@2.1.14", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "find-up": "^5.0.0", "glob": "^10.4.2", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-nT5ERXwc+0ZT/pozDoJjYZyUQu5RnXMk9jDGm5lg+PiKvsrCTSA/2/eftJGMxLkTjVI2MXp5WjSz3JRjbA7UXA=="], + "expo-modules-autolinking": ["expo-modules-autolinking@3.0.21", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-pOtPDLln3Ju8DW1zRW4OwZ702YqZ8g+kM/tEY1sWfv22kWUtxkvK+ytRDRpRdnKEnC28okbhWqeMnmVkSFzP6Q=="], - "expo-modules-core": ["expo-modules-core@2.5.0", "", { "dependencies": { "invariant": "^2.2.4" } }, "sha512-aIbQxZE2vdCKsolQUl6Q9Farlf8tjh/ROR4hfN1qT7QBGPl1XrJGnaOKkcgYaGrlzCPg/7IBe0Np67GzKMZKKQ=="], + "expo-modules-core": ["expo-modules-core@3.0.25", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0P8PT8UV6c5/+p8zeVM/FXvBgn/ErtGcMaasqUgbzzBUg94ktbkIrij9t9reGCrir03BYt/Bcpv+EQtYC8JOug=="], - "expo-notifications": ["expo-notifications@0.31.4", "", { "dependencies": { "@expo/image-utils": "^0.7.6", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", "expo-application": "~6.1.5", "expo-constants": "~17.1.7" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-NnGKIFGpgZU66qfiFUyjEBYsS77VahURpSSeWEOLt+P1zOaUFlgx2XqS+dxH3/Bn1Vm7TMj04qKsK5KvzR/8Lw=="], + "expo-notifications": ["expo-notifications@0.32.12", "", { "dependencies": { "@expo/image-utils": "^0.8.7", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", "expo-application": "~7.0.7", "expo-constants": "~18.0.9" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-FVJ5W4rOpKvmrLJ1Sd5pxiVTV4a7ApgTlKro+E5X8M2TBbXmEVOjs09klzdalXTjlzmU/Gu8aRw9xr7Ea/gZdw=="], - "expo-router": ["expo-router@5.1.7", "", { "dependencies": { "@expo/metro-runtime": "5.0.5", "@expo/schema-utils": "^0.1.0", "@expo/server": "^0.6.3", "@radix-ui/react-slot": "1.2.0", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/native": "^7.1.6", "@react-navigation/native-stack": "^7.3.10", "client-only": "^0.0.1", "invariant": "^2.2.4", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "shallowequal": "^1.1.0" }, "peerDependencies": { "@react-navigation/drawer": "^7.3.9", "expo": "*", "expo-constants": "*", "expo-linking": "*", "react-native-reanimated": "*", "react-native-safe-area-context": "*", "react-native-screens": "*" }, "optionalPeers": ["@react-navigation/drawer", "react-native-reanimated"] }, "sha512-E7hIqTZs4Cub4sbYPeednfYPi+2cyRGMdqc5IYBJ/vC+WBKoYJ8C9eU13ZLbPz//ZybSo2Dsm7v89uFIlO2Gow=="], + "expo-router": ["expo-router@6.0.14", "", { "dependencies": { "@expo/metro-runtime": "^6.1.2", "@expo/schema-utils": "^0.1.7", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/native": "^7.1.8", "@react-navigation/native-stack": "^7.3.16", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-server": "^1.0.3", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@react-navigation/drawer": "^7.5.0", "@testing-library/react-native": ">= 12.0.0", "expo": "*", "expo-constants": "^18.0.10", "expo-linking": "^8.0.8", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": ">= 19.0.0" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-vizLO4SgnMEL+PPs2dXr+etEOuksjue7yUQBCtfCEdqoDkQlB0r35zI7rS34Wt53sxKWSlM2p+038qQEpxtiFw=="], - "expo-screen-orientation": ["expo-screen-orientation@8.1.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-nYwadYtdU6mMDk0MCHMPPPQtBoeFYJ2FspLRW+J35CMLqzE4nbpwGeiImfXzkvD94fpOCfI4KgLj5vGauC3pfA=="], + "expo-screen-orientation": ["expo-screen-orientation@9.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-UH/XlB9eMw+I2cyHSkXhAHRAPk83WyA3k5bst7GLu14wRuWiTch9fb6I7qEJK5CN6+XelcWxlBJymys6Fr/FKA=="], - "expo-sensors": ["expo-sensors@14.1.4", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-KHROi5C8dhXedMwx7fZ5eyv9p382F5XOIex4a+GpdOTL3OY4xyk08kt7x64FtMeeoT87gYD3mb9LrBpHyNubkg=="], + "expo-sensors": ["expo-sensors@15.0.7", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-TGUxRx/Ss7KGgfWo453YF64ENucw6oYryPiu/8I3ZZuf114xQPRxAbsZohPLaVUUGuaUyWbDsb0eRsmuKUzBnQ=="], - "expo-sharing": ["expo-sharing@13.1.5", "", { "peerDependencies": { "expo": "*" } }, "sha512-X/5sAEiWXL2kdoGE3NO5KmbfcmaCWuWVZXHu8OQef7Yig4ZgHFkGD11HKJ5KqDrDg+SRZe4ISd6MxE7vGUgm4w=="], + "expo-server": ["expo-server@1.0.4", "", {}, "sha512-IN06r3oPxFh3plSXdvBL7dx0x6k+0/g0bgxJlNISs6qL5Z+gyPuWS750dpTzOeu37KyBG0RcyO9cXUKzjYgd4A=="], - "expo-splash-screen": ["expo-splash-screen@0.30.10", "", { "dependencies": { "@expo/prebuild-config": "^9.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-Tt9va/sLENQDQYeOQ6cdLdGvTZ644KR3YG9aRlnpcs2/beYjOX1LHT510EGzVN9ljUTg+1ebEo5GGt2arYtPjw=="], + "expo-sharing": ["expo-sharing@14.0.7", "", { "peerDependencies": { "expo": "*" } }, "sha512-t/5tR8ZJNH6tMkHXlF7453UafNIfrpfTG+THN9EMLC4Wsi4bJuESPm3NdmWDg2D4LDALJI/LQo0iEnLAd5Sp4g=="], - "expo-status-bar": ["expo-status-bar@2.2.3", "", { "dependencies": { "react-native-edge-to-edge": "1.6.0", "react-native-is-edge-to-edge": "^1.1.6" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+c8R3AESBoduunxTJ8353SqKAKpxL6DvcD8VKBuh81zzJyUUbfB4CVjr1GufSJEKsMzNPXZU+HJwXx7Xh7lx8Q=="], + "expo-splash-screen": ["expo-splash-screen@31.0.10", "", { "dependencies": { "@expo/prebuild-config": "^54.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-i6g9IK798mae4yvflstQ1HkgahIJ6exzTCTw4vEdxV0J2SwiW3Tj+CwRjf0te7Zsb+7dDQhBTmGZwdv00VER2A=="], - "expo-system-ui": ["expo-system-ui@5.0.11", "", { "dependencies": { "@react-native/normalize-colors": "0.79.6", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-PG5VdaG5cwBe1Rj02mJdnsihKl9Iw/w/a6+qh2mH3f2K/IvQ+Hf7aG2kavSADtkGNCNj7CEIg7Rn4DQz/SE5rQ=="], + "expo-status-bar": ["expo-status-bar@3.0.8", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-L248XKPhum7tvREoS1VfE0H6dPCaGtoUWzRsUv7hGKdiB4cus33Rc0sxkWkoQ77wE8stlnUlL5lvmT0oqZ3ZBw=="], - "expo-task-manager": ["expo-task-manager@13.1.6", "", { "dependencies": { "unimodules-app-loader": "~5.1.3" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-sYNAftpIeZ+j6ur17Jo0OpSTk9ks/MDvTbrNCimXMyjIt69XXYL/kAPYf76bWuxOuN8bcJ8Ef8YvihkwFG9hDA=="], + "expo-system-ui": ["expo-system-ui@6.0.8", "", { "dependencies": { "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-DzJYqG2fibBSLzPDL4BybGCiilYOtnI1OWhcYFwoM4k0pnEzMBt1Vj8Z67bXglDDuz2HCQPGNtB3tQft5saKqQ=="], - "expo-updates-interface": ["expo-updates-interface@1.1.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-DeB+fRe0hUDPZhpJ4X4bFMAItatFBUPjw/TVSbJsaf3Exeami+2qbbJhWkcTMoYHOB73nOIcaYcWXYJnCJXO0w=="], + "expo-task-manager": ["expo-task-manager@14.0.8", "", { "dependencies": { "unimodules-app-loader": "~6.0.7" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-HxhyvmulM8px+LQvqIKS85KVx2UodZf5RO+FE2ltpC4mQ5IFkX/ESqiK0grzDa4pVFLyxvs8LjuUKsfB5c39PQ=="], - "expo-web-browser": ["expo-web-browser@14.2.0", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6S51d8pVlDRDsgGAp8BPpwnxtyKiMWEFdezNz+5jVIyT+ctReW42uxnjRgtsdn5sXaqzhaX+Tzk/CWaKCyC0hw=="], + "expo-updates-interface": ["expo-updates-interface@2.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg=="], + + "expo-web-browser": ["expo-web-browser@15.0.9", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Dj8kNFO+oXsxqCDNlUT/GhOrJnm10kAElH++3RplLydogFm5jTzXYWDEeNIDmV+F+BzGYs+sIhxiBf7RyaxXZg=="], "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], @@ -1091,6 +1082,8 @@ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="], + "flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="], "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], @@ -1141,6 +1134,8 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "global-dirs": ["global-dirs@0.1.1", "", { "dependencies": { "ini": "^1.3.4" } }, "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -1155,9 +1150,9 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + "hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="], - "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], @@ -1175,7 +1170,7 @@ "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], - "i18next": ["i18next@25.6.0", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw=="], + "i18next": ["i18next@25.6.1", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-yUWvdXtalZztmKrKw3yz/AvSP3yKyqIkVPx/wyvoYy9lkLmwzItLxp0iHZLG5hfVQ539Jor4XLO+U+NHIXg7pw=="], "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], @@ -1211,8 +1206,6 @@ "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], - "is-directory": ["is-directory@0.3.1", "", {}, "sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw=="], - "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -1237,7 +1230,9 @@ "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], - "is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -1249,7 +1244,7 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - "jest-diff": ["jest-diff@30.2.0", "", { "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.2.0" } }, "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A=="], + "jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="], "jest-environment-node": ["jest-environment-node@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw=="], @@ -1257,15 +1252,15 @@ "jest-haste-map": ["jest-haste-map@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", "graceful-fs": "^4.2.9", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.2" } }, "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA=="], - "jest-matcher-utils": ["jest-matcher-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.2.0", "pretty-format": "30.2.0" } }, "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg=="], + "jest-matcher-utils": ["jest-matcher-utils@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g=="], - "jest-message-util": ["jest-message-util@30.2.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.2.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "micromatch": "^4.0.8", "pretty-format": "30.2.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw=="], + "jest-message-util": ["jest-message-util@29.7.0", "", { "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "micromatch": "^4.0.4", "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" } }, "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w=="], - "jest-mock": ["jest-mock@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "jest-util": "30.2.0" } }, "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw=="], + "jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="], "jest-regex-util": ["jest-regex-util@29.6.3", "", {}, "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg=="], - "jest-util": ["jest-util@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.2" } }, "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA=="], + "jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], "jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="], @@ -1277,7 +1272,7 @@ "joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA=="], - "jotai": ["jotai@2.15.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-nbp/6jN2Ftxgw0VwoVnOg0m5qYM1rVcfvij+MZx99Z5IK13eGve9FJoCwGv+17JvVthTjhSmNtT5e1coJnr6aw=="], + "jotai": ["jotai@2.15.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-yHT1HAZ3ba2Q8wgaUQ+xfBzEtcS8ie687I8XVCBinfg4bNniyqLIN+utPXWKQE93LMF5fPbQSVRZqgpcN5yd6Q=="], "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], @@ -1289,16 +1284,20 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="], - "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-stable-stringify": ["json-stable-stringify@1.3.0", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="], + + "klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="], + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "lan-network": ["lan-network@0.1.7", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ=="], @@ -1309,27 +1308,29 @@ "lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="], - "lightningcss": ["lightningcss@1.27.0", "", { "dependencies": { "detect-libc": "^1.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.27.0", "lightningcss-darwin-x64": "1.27.0", "lightningcss-freebsd-x64": "1.27.0", "lightningcss-linux-arm-gnueabihf": "1.27.0", "lightningcss-linux-arm64-gnu": "1.27.0", "lightningcss-linux-arm64-musl": "1.27.0", "lightningcss-linux-x64-gnu": "1.27.0", "lightningcss-linux-x64-musl": "1.27.0", "lightningcss-win32-arm64-msvc": "1.27.0", "lightningcss-win32-x64-msvc": "1.27.0" } }, "sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ=="], + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gl/lqIXY+d+ySmMbgDf0pgaWSqrWYxVHoc88q+Vhf2YNzZ8DwoRzGt5NZDVqqIW5ScpSnmmjcgXP87Dn2ylSSQ=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0+mZa54IlcNAoQS9E0+niovhyjjQWEMrwW0p2sSdLRhLDc8LMQ/b67z7+B5q4VmjYCMSfnFi3djAAQFIDuj/Tg=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-n1sEf85fePoU2aDN2PzYjoI8gbBqnmLGEhKq7q0DKLj0UTVmOTwDC7PtLcy/zFxzASTSBlVQYJUhwIStQMIpRA=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-MUMRmtdRkOkd5z3h986HOuNBD1c2lq2BSQA1Jg88d9I7bmPGx08bwGcnB75dvr17CwxjxD6XPi3Qh8ArmKFqCA=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-cPsxo1QEWq2sfKkSq2Bq5feQDHdUEwgtA9KaB27J5AX22+l4l0ptgjMZZtYtUnteBofjee+0oW1wQ1guv04a7A=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], @@ -1373,33 +1374,33 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "metro": ["metro@0.82.5", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.29.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.82.5", "metro-cache": "0.82.5", "metro-cache-key": "0.82.5", "metro-config": "0.82.5", "metro-core": "0.82.5", "metro-file-map": "0.82.5", "metro-resolver": "0.82.5", "metro-runtime": "0.82.5", "metro-source-map": "0.82.5", "metro-symbolicate": "0.82.5", "metro-transform-plugins": "0.82.5", "metro-transform-worker": "0.82.5", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-8oAXxL7do8QckID/WZEKaIFuQJFUTLzfVcC48ghkHhNK2RGuQq8Xvf4AVd+TUA0SZtX0q8TGNXZ/eba1ckeGCg=="], + "metro": ["metro@0.83.2", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-symbolicate": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-HQgs9H1FyVbRptNSMy/ImchTTE5vS2MSqLoOo7hbDoBq6hPPZokwJvBMwrYSxdjQZmLXz2JFZtdvS+ZfgTc9yw=="], - "metro-babel-transformer": ["metro-babel-transformer@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.29.1", "nullthrows": "^1.1.1" } }, "sha512-W/scFDnwJXSccJYnOFdGiYr9srhbHPdxX9TvvACOFsIXdLilh3XuxQl/wXW6jEJfgIb0jTvoTlwwrqvuwymr6Q=="], + "metro-babel-transformer": ["metro-babel-transformer@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.32.0", "nullthrows": "^1.1.1" } }, "sha512-rirY1QMFlA1uxH3ZiNauBninwTioOgwChnRdDcbB4tgRZ+bGX9DiXoh9QdpppiaVKXdJsII932OwWXGGV4+Nlw=="], - "metro-cache": ["metro-cache@0.82.5", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.82.5" } }, "sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q=="], + "metro-cache": ["metro-cache@0.83.2", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.2" } }, "sha512-Z43IodutUZeIS7OTH+yQFjc59QlFJ6s5OvM8p2AP9alr0+F8UKr8ADzFzoGKoHefZSKGa4bJx7MZJLF6GwPDHQ=="], - "metro-cache-key": ["metro-cache-key@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-qpVmPbDJuRLrT4kcGlUouyqLGssJnbTllVtvIgXfR7ZuzMKf0mGS+8WzcqzNK8+kCyakombQWR0uDd8qhWGJcA=="], + "metro-cache-key": ["metro-cache-key@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-3EMG/GkGKYoTaf5RqguGLSWRqGTwO7NQ0qXKmNBjr0y6qD9s3VBXYlwB+MszGtmOKsqE9q3FPrE5Nd9Ipv7rZw=="], - "metro-config": ["metro-config@0.82.5", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.82.5", "metro-cache": "0.82.5", "metro-core": "0.82.5", "metro-runtime": "0.82.5" } }, "sha512-/r83VqE55l0WsBf8IhNmc/3z71y2zIPe5kRSuqA5tY/SL/ULzlHUJEMd1szztd0G45JozLwjvrhAzhDPJ/Qo/g=="], + "metro-config": ["metro-config@0.83.2", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.2", "metro-cache": "0.83.2", "metro-core": "0.83.2", "metro-runtime": "0.83.2", "yaml": "^2.6.1" } }, "sha512-1FjCcdBe3e3D08gSSiU9u3Vtxd7alGH3x/DNFqWDFf5NouX4kLgbVloDDClr1UrLz62c0fHh2Vfr9ecmrOZp+g=="], - "metro-core": ["metro-core@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.82.5" } }, "sha512-OJL18VbSw2RgtBm1f2P3J5kb892LCVJqMvslXxuxjAPex8OH7Eb8RBfgEo7VZSjgb/LOf4jhC4UFk5l5tAOHHA=="], + "metro-core": ["metro-core@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.2" } }, "sha512-8DRb0O82Br0IW77cNgKMLYWUkx48lWxUkvNUxVISyMkcNwE/9ywf1MYQUE88HaKwSrqne6kFgCSA/UWZoUT0Iw=="], - "metro-file-map": ["metro-file-map@0.82.5", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-vpMDxkGIB+MTN8Af5hvSAanc6zXQipsAUO+XUx3PCQieKUfLwdoa8qaZ1WAQYRpaU+CJ8vhBcxtzzo3d9IsCIQ=="], + "metro-file-map": ["metro-file-map@0.83.2", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-cMSWnEqZrp/dzZIEd7DEDdk72PXz6w5NOKriJoDN9p1TDQ5nAYrY2lHi8d6mwbcGLoSlWmpPyny9HZYFfPWcGQ=="], - "metro-minify-terser": ["metro-minify-terser@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-v6Nx7A4We6PqPu/ta1oGTqJ4Usz0P7c+3XNeBxW9kp8zayS3lHUKR0sY0wsCHInxZlNAEICx791x+uXytFUuwg=="], + "metro-minify-terser": ["metro-minify-terser@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-zvIxnh7U0JQ7vT4quasKsijId3dOAWgq+ip2jF/8TMrPUqQabGrs04L2dd0haQJ+PA+d4VvK/bPOY8X/vL2PWw=="], - "metro-resolver": ["metro-resolver@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g=="], + "metro-resolver": ["metro-resolver@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Yf5mjyuiRE/Y+KvqfsZxrbHDA15NZxyfg8pIk0qg47LfAJhpMVEX+36e6ZRBq7KVBqy6VDX5Sq55iHGM4xSm7Q=="], - "metro-runtime": ["metro-runtime@0.82.5", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-rQZDoCUf7k4Broyw3Ixxlq5ieIPiR1ULONdpcYpbJQ6yQ5GGEyYjtkztGD+OhHlw81LCR2SUAoPvtTus2WDK5g=="], + "metro-runtime": ["metro-runtime@0.83.3", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw=="], - "metro-source-map": ["metro-source-map@0.82.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.82.5", "nullthrows": "^1.1.1", "ob1": "0.82.5", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-wH+awTOQJVkbhn2SKyaw+0cd+RVSCZ3sHVgyqJFQXIee/yLs3dZqKjjeKKhhVeudgjXo7aE/vSu/zVfcQEcUfw=="], + "metro-source-map": ["metro-source-map@0.83.3", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.3", "nullthrows": "^1.1.1", "ob1": "0.83.3", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg=="], - "metro-symbolicate": ["metro-symbolicate@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.82.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw=="], + "metro-symbolicate": ["metro-symbolicate@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.3", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw=="], - "metro-transform-plugins": ["metro-transform-plugins@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-57Bqf3rgq9nPqLrT2d9kf/2WVieTFqsQ6qWHpEng5naIUtc/Iiw9+0bfLLWSAw0GH40iJ4yMjFcFJDtNSYynMA=="], + "metro-transform-plugins": ["metro-transform-plugins@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-5WlW25WKPkiJk2yA9d8bMuZrgW7vfA4f4MBb9ZeHbTB3eIAoNN8vS8NENgG/X/90vpTB06X66OBvxhT3nHwP6A=="], - "metro-transform-worker": ["metro-transform-worker@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.82.5", "metro-babel-transformer": "0.82.5", "metro-cache": "0.82.5", "metro-cache-key": "0.82.5", "metro-minify-terser": "0.82.5", "metro-source-map": "0.82.5", "metro-transform-plugins": "0.82.5", "nullthrows": "^1.1.1" } }, "sha512-mx0grhAX7xe+XUQH6qoHHlWedI8fhSpDGsfga7CpkO9Lk9W+aPitNtJWNGrW8PfjKEWbT9Uz9O50dkI8bJqigw=="], + "metro-transform-worker": ["metro-transform-worker@0.83.2", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.83.2", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-minify-terser": "0.83.2", "metro-source-map": "0.83.2", "metro-transform-plugins": "0.83.2", "nullthrows": "^1.1.1" } }, "sha512-G5DsIg+cMZ2KNfrdLnWMvtppb3+Rp1GMyj7Bvd9GgYc/8gRmvq1XVEF9XuO87Shhb03kFhGqMTgZerz3hZ1v4Q=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -1461,7 +1462,7 @@ "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="], - "ob1": ["ob1@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-QyQQ6e66f+Ut/qUVjEce0E/wux5nAGLXYZDn1jr15JWstHsCH3l6VVrg8NKDptW9NEiBXKOJeGF/ydxeSDF3IQ=="], + "ob1": ["ob1@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -1485,7 +1486,7 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="], + "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], @@ -1507,6 +1508,8 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "patch-package": ["patch-package@8.0.1", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^10.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.2.4", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], @@ -1557,9 +1560,11 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "postinstall-postinstall": ["postinstall-postinstall@2.1.0", "", {}, "sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ=="], + "pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="], - "pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="], + "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], @@ -1593,11 +1598,11 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "react": ["react@19.0.0", "", {}, "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ=="], + "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], - "react-dom": ["react-dom@19.0.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ=="], + "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], @@ -1607,11 +1612,11 @@ "react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="], - "react-native": ["react-native-tvos@0.79.5-0", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native-tvos/virtualized-lists": "0.79.5-0", "@react-native/assets-registry": "0.79.5", "@react-native/codegen": "0.79.5", "@react-native/community-cli-plugin": "0.79.5", "@react-native/gradle-plugin": "0.79.5", "@react-native/js-polyfills": "0.79.5", "@react-native/normalize-colors": "0.79.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.25.1", "base64-js": "^1.5.1", "chalk": "^4.0.0", "commander": "^12.0.0", "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.82.0", "metro-source-map": "^0.82.0", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.1", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.25.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "^19.0.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-ayl3M5t+HEib1Kl46QK+VQuFTwY7uAKpLjZlVRlVPMoIVWKa8+gqxM8CeCeaDL+KEbC5cMYxa300fQWG5WJMZQ=="], + "react-native": ["react-native-tvos@0.81.5-1", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native-tvos/virtualized-lists": "0.81.5-1", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-jEZ5S8Urjaxkb/pQsfxXslTtKGfeBdaXwEObTyAF3PvCT0wYKD4NbftVJC5Iid9/jKeoBfWTuAOTFfaivqx7IA=="], "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.12.2", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-OXxYtKbJK8hfV7ZrrS/h3vmoB2WOQQRBuV+cqJ1NJSpKxZNBIwHfbUxGlkgcg/I7x2GlmZ9yDDC3KbQ3ouv69Q=="], + "react-native-bottom-tabs": ["react-native-bottom-tabs@1.0.2", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-eWNuTpJVefKRaROda4ZeWHvW1cUEb0mw8L7FyLEcPPsd7Tp3rfLRrhptl/O/3mAki9gvpzYE8ASE3GwUrjfp+Q=="], "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=="], @@ -1621,9 +1626,9 @@ "react-native-device-info": ["react-native-device-info@14.1.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-lXFpe6DJmzbQXNLWxlMHP2xuTU5gwrKAvI8dCAZuERhW9eOXSubOQIesk9lIBnsi9pI19GMrcpJEvs4ARPRYmw=="], - "react-native-edge-to-edge": ["react-native-edge-to-edge@1.6.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-2WCNdE3Qd6Fwg9+4BpbATUxCLcouF6YRY7K+J36KJ4l3y+tWN6XCqAC4DuoGblAAbb2sLkhEDp4FOlbOIot2Og=="], + "react-native-edge-to-edge": ["react-native-edge-to-edge@1.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ERegbsq28yoMndn/Uq49i4h6aAhMvTEjOfkFh50yX9H/dMjjCr/Tix/es/9JcPRvC+q7VzCMWfxWDUb6Jrq1OQ=="], - "react-native-gesture-handler": ["react-native-gesture-handler@2.24.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ZdWyOd1C8axKJHIfYxjJKCcxjWEpUtUWgTOVY2wynbiveSQDm8X/PDyAKXSer/GOtIpjudUbACOndZXCN3vHsw=="], + "react-native-gesture-handler": ["react-native-gesture-handler@2.28.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A=="], "react-native-google-cast": ["react-native-google-cast@4.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/HvIKAaWHtG6aTNCxrNrqA2ftWGkfH0M/2iN+28pdGUXpKmueb33mgL1m8D4zzwEODQMcmpfoCsym1IwDvugBQ=="], @@ -1631,23 +1636,25 @@ "react-native-ios-context-menu": ["react-native-ios-context-menu@3.2.1", "", { "dependencies": { "@dominicstop/ts-event-emitter": "^1.1.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-ios-utilities": "*" } }, "sha512-OBQbb3I/VUx2wQgz4cqN614kt3nJ+qx5wxEdtGN1Aj4nYYL1orp7VLFkV6axof6DgOyv0YD6af2RUTok6a2xDQ=="], - "react-native-ios-utilities": ["react-native-ios-utilities@5.1.8", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-2lWerAkd0Kn18kUAc/RaBzHnOGG1VjbKVfTR4eEXvwYFYqCS709gOg0tGUaVLsm6CAyMe7/jA+AvKMMztzHf4g=="], + "react-native-ios-utilities": ["react-native-ios-utilities@5.2.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-RTw1Gk8rQhBL43+U80I+Nu8T7mLTNkj5RaG8vTs3ETEDqphS3L0Mrzk79RX0Jmm64HMad70GXHctXFlW1n0V8w=="], "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], - "react-native-mmkv": ["react-native-mmkv@2.12.2", "", { "peerDependencies": { "react": "*", "react-native": ">=0.71.0" } }, "sha512-6058Aq0p57chPrUutLGe9fYoiDVDNMU2PKV+lLFUJ3GhoHvUrLdsS1PDSCLr00yqzL4WJQ7TTzH+V8cpyrNcfg=="], + "react-native-mmkv": ["react-native-mmkv@4.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-Osoy8as2ZLzO1TTsKxc4tX14Qk19qRVMWnS4ZVBwxie9Re5cjt7rqlpDkJczK3H/y3z70EQ6rmKI/cNMCLGAYQ=="], + + "react-native-nitro-modules": ["react-native-nitro-modules@0.31.5", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-h/IbVsK5IH7JkvseihAoz/o5dy6CafvGo7j4jTvAa+gnxZWFtXQZg8EDvu0en88LFAumKd/pcF20dzxMiNOmug=="], "react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="], - "react-native-reanimated": ["react-native-reanimated@3.19.3", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", "@babel/plugin-transform-classes": "^7.0.0-0", "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", "@babel/plugin-transform-optional-chaining": "^7.0.0-0", "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", "@babel/plugin-transform-template-literals": "^7.0.0-0", "@babel/plugin-transform-unicode-regex": "^7.0.0-0", "@babel/preset-typescript": "^7.16.7", "convert-source-map": "^2.0.0", "invariant": "^2.2.4", "react-native-is-edge-to-edge": "1.1.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*" } }, "sha512-96ZSE6VRpBAAkrLcnbtCRHvjCqF+jtJCA+in6BN/fu0XUquJAxJHZr8kCt4aHMv6Aqxo6cm9sQEkjMj68JJnZg=="], + "react-native-reanimated": ["react-native-reanimated@4.1.3", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*", "react-native-worklets": ">=0.5.0" } }, "sha512-GP8wsi1u3nqvC1fMab/m8gfFwFyldawElCcUSBJQgfrXeLmsPPUOpDw44lbLeCpcwUuLa05WTVePdTEwCLTUZg=="], "react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.2", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-vNpCfPlFoOVKHd+oB7B0luoJswp+nyz0NdJD8+LCrf25JiNQXfM22RSJhLaksBHqk3fm8R4fKWPNcfy5w7wL1Q=="], - "react-native-safe-area-context": ["react-native-safe-area-context@5.4.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-JaEThVyJcLhA+vU0NU8bZ0a1ih6GiF4faZ+ArZLqpYbL6j7R3caRqj+mE3lEtKCuHgwjLg3bCxLL1GPUJZVqUA=="], + "react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="], - "react-native-screens": ["react-native-screens@4.11.1", "", { "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.1.7", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-F0zOzRVa3ptZfLpD0J8ROdo+y1fEPw+VBFq1MTY/iyDu08al7qFUO5hLMd+EYMda5VXGaTFCa8q7bOppUszhJw=="], + "react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="], - "react-native-svg": ["react-native-svg@15.11.2", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+YfF72IbWQUKzCIydlijV1fLuBsQNGMT6Da2kFlo1sh+LE3BIm/2Q7AR1zAAR6L0BFLi1WaQPLfFUC9bNZpOmw=="], + "react-native-svg": ["react-native-svg@15.12.1", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g=="], "react-native-tab-view": ["react-native-tab-view@4.2.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-TUbh7Yr0tE/99t1pJQLbQ+4/Px67xkT7/r3AhfV+93Q3WoUira0Lx7yuKUP2C118doqxub8NCLERwcqsHr29nQ=="], @@ -1657,11 +1664,13 @@ "react-native-uuid": ["react-native-uuid@2.0.3", "", {}, "sha512-f/YfIS2f5UB+gut7t/9BKGSCYbRA9/74A5R1MDp+FLYsuS+OSWoiM/D8Jko6OJB6Jcu3v6ONuddvZKHdIGpeiw=="], - "react-native-video": ["react-native-video@6.14.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-nTPHqg+GKu6UV6Hr5Ph48Hn2jod963bHCt0wWtlK9jv9lE1FDGyDdX0Jl15lky8v7VhNdqjqb9DX0EUhMontWg=="], + "react-native-video": ["react-native-video@6.16.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+G6tVVGbwFqNTyPivqb+PhQzWr5OudDQ1dgvBNyBRAgcS8rOcbwuS6oX+m8cxOsXHn1UT9ofQnjQEwkGOsvomg=="], "react-native-volume-manager": ["react-native-volume-manager@2.0.8", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-aZM47/mYkdQ4CbXpKYO6Ajiczv7fxbQXZ9c0H8gRuQUaS3OCz/MZABer6o9aDWq0KMNsQ7q7GVFLRPnSSeeMmw=="], - "react-native-web": ["react-native-web@0.20.0", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-OOSgrw+aON6R3hRosCau/xVxdLzbjEcsLysYedka0ZON4ZZe6n9xgeN9ZkoejhARM36oTlUgHIQqxGutEJ9Wxg=="], + "react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="], + + "react-native-worklets": ["react-native-worklets@0.5.1", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", "@babel/plugin-transform-classes": "^7.0.0-0", "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", "@babel/plugin-transform-optional-chaining": "^7.0.0-0", "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", "@babel/plugin-transform-template-literals": "^7.0.0-0", "@babel/plugin-transform-unicode-regex": "^7.0.0-0", "@babel/preset-typescript": "^7.16.7", "convert-source-map": "^2.0.0", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*" } }, "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w=="], "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], @@ -1681,8 +1690,6 @@ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "recyclerlistview": ["recyclerlistview@4.2.3", "", { "dependencies": { "lodash.debounce": "4.0.8", "prop-types": "15.8.1", "ts-object-utils": "0.0.5" }, "peerDependencies": { "react": ">= 15.2.1", "react-native": ">= 0.30.0" } }, "sha512-STR/wj/FyT8EMsBzzhZ1l2goYirMkIgfV3gYEPxI3Kf3lOnu6f7Dryhyw7/IkQrgX5xtTcDrZMqytvteH9rL3g=="], - "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], @@ -1707,6 +1714,8 @@ "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "resolve-global": ["resolve-global@1.0.0", "", { "dependencies": { "global-dirs": "^0.1.1" } }, "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw=="], + "resolve-workspace-root": ["resolve-workspace-root@2.0.0", "", {}, "sha512-IsaBUZETJD5WsI11Wt8PKHwaIe45or6pwNc8yflvLJ4DWtImK9kuLoH5kUva/2Mmx/RdIyr4aONNSa2v9LTJsw=="], "resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="], @@ -1729,7 +1738,7 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], + "sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="], "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], @@ -1777,7 +1786,7 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="], "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], @@ -1847,7 +1856,7 @@ "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="], - "terser": ["terser@5.44.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w=="], + "terser": ["terser@5.44.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw=="], "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="], @@ -1861,6 +1870,8 @@ "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], "to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="], @@ -1875,8 +1886,6 @@ "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], - "ts-object-utils": ["ts-object-utils@0.0.5", "", {}, "sha512-iV0GvHqOmilbIKJsfyfJY9/dNHCs969z3so90dQWsO1eMMozvTpnB1MEaUbb3FYtZTGjv5sIy/xmslEz0Rg2TA=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], @@ -1885,7 +1894,7 @@ "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="], @@ -1901,7 +1910,7 @@ "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], - "unimodules-app-loader": ["unimodules-app-loader@5.1.3", "", {}, "sha512-nPUkwfkpJWvdOQrVvyQSUol93/UdmsCVd9Hkx9RgAevmKSVYdZI+S87W73NGKl6QbwK9L1BDSY5OrQuo8Oq15g=="], + "unimodules-app-loader": ["unimodules-app-loader@6.0.7", "", {}, "sha512-23iwxmh6/y54PRGJt/xjsOpPK8vlfusBisi3yaVSK22pxg5DmiL/+IHCtbb/crHC+gqdItcy1OoRsZQHfNSBaw=="], "unique-string": ["unique-string@2.0.0", "", { "dependencies": { "crypto-random-string": "^2.0.0" } }, "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg=="], @@ -1911,8 +1920,6 @@ "update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="], - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-debounce": ["use-debounce@10.0.6", "", { "peerDependencies": { "react": "*" } }, "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg=="], @@ -1937,6 +1944,8 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + "vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], @@ -1991,10 +2000,10 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zeego": ["zeego@3.0.6", "", { "dependencies": { "@radix-ui/react-context-menu": "^2.0.1", "@radix-ui/react-dropdown-menu": "^2.0.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "@react-native-menu/menu": "1.2.2", "react": "*", "react-native": "*", "react-native-ios-context-menu": "3.1.0", "react-native-ios-utilities": "5.1.2" } }, "sha512-vg0GCMPYg6or/J91bwRnUpIYwz7QnhkyeKOdd3FjvICg+Gzq2D5QhD8k5RUSv1B+048LpNmNYdLm8qJVIbBONw=="], - "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], + "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="], + "@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], "@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], @@ -2013,8 +2022,6 @@ "@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="], - "@expo/cli/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], - "@expo/cli/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], "@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -2035,6 +2042,8 @@ "@expo/config-plugins/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], + "@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "@expo/devcert/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], @@ -2053,14 +2062,20 @@ "@expo/json-file/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], + "@expo/mcp-tunnel/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "@expo/mcp-tunnel/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@expo/metro/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="], + + "@expo/metro/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="], + "@expo/metro-config/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], "@expo/metro-config/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], "@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], - "@expo/package-manager/@expo/json-file": ["@expo/json-file@10.0.7", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3" } }, "sha512-z2OTC0XNO6riZu98EjdNHC05l51ySeTto6GP7oSQrCvQgG9ARBwD1YvMQaVZ9wU7p/4LzSf1O7tckL3B45fPpw=="], - "@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], "@expo/prebuild-config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], @@ -2079,47 +2094,25 @@ "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], - "@jest/environment/jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="], - - "@jest/fake-timers/jest-message-util": ["jest-message-util@29.7.0", "", { "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "micromatch": "^4.0.4", "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" } }, "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w=="], - - "@jest/fake-timers/jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="], - - "@jest/fake-timers/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], - - "@jest/pattern/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], - - "@jest/transform/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], - - "@jest/types/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "@jest/transform/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "@jimp/png/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@react-native-community/cli/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], + "@react-native-community/cli/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - "@react-native-community/cli-doctor/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], + "@react-native-community/cli-doctor/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - "@react-native-community/cli-server-api/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "@react-native-community/cli-server-api/open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="], - "@react-native-community/cli-tools/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], + "@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - "@react-native/babel-plugin-codegen/@react-native/codegen": ["@react-native/codegen@0.79.6", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.25.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-iRBX8Lgbqypwnfba7s6opeUwVyaR23mowh9ILw7EcT2oLz3RqMmjJdrbVpWhGSMGq2qkPfqAH7bhO8C7O+xfjQ=="], - - "@react-native/community-cli-plugin/@react-native/dev-middleware": ["@react-native/dev-middleware@0.79.5", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.79.5", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^2.2.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^6.2.3" } }, "sha512-U7r9M/SEktOCP/0uS6jXMHmYjj4ESfYCkNAenBjFjjsRWekiHE+U/vRMeO+fG9gq4UCcBAUISClkQCowlftYBw=="], - - "@react-native/community-cli-plugin/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "@react-native/community-cli-plugin/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], - - "@react-native/dev-middleware/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + "@react-native/community-cli-plugin/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "@react-navigation/bottom-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], @@ -2139,20 +2132,16 @@ "ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "caller-callsite/callsites": ["callsites@2.0.0", "", {}, "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ=="], - "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - - "chromium-edge-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - "cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -2167,12 +2156,8 @@ "expo-build-properties/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], - "expo-dev-launcher/ajv": ["ajv@8.11.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg=="], - "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], - "expo-modules-autolinking/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], - "expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -2201,25 +2186,7 @@ "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - "jest-environment-node/jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="], - - "jest-environment-node/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], - - "jest-haste-map/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], - - "jest-message-util/@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="], - - "jest-mock/@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="], - - "jest-util/@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="], - - "jest-util/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], - - "jest-util/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - - "jest-validate/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], - - "jest-worker/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], + "jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], @@ -2235,13 +2202,21 @@ "metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], - "metro/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="], + "metro/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], + + "metro/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="], + + "metro/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="], + + "metro/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="], "metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], - "metro-babel-transformer/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="], + "metro-babel-transformer/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], - "metro-config/cosmiconfig": ["cosmiconfig@5.2.1", "", { "dependencies": { "import-fresh": "^2.0.0", "is-directory": "^0.3.1", "js-yaml": "^3.13.1", "parse-json": "^4.0.0" } }, "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA=="], + "metro-config/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="], + + "metro-transform-worker/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="], "nativewind/@babel/types": ["@babel/types@7.19.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.18.10", "@babel/helper-validator-identifier": "^7.18.6", "to-fast-properties": "^2.0.0" } }, "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA=="], @@ -2251,6 +2226,10 @@ "npm-package-arg/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], + "patch-package/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + + "patch-package/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], @@ -2265,24 +2244,18 @@ "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], - "react-dom/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], - - "react-native/@react-native/normalize-colors": ["@react-native/normalize-colors@0.79.5", "", {}, "sha512-nGXMNMclZgzLUxijQQ38Dm3IAEhgxuySAWQHnljFtfB0JdaMwpe0Ox9H7Tp2OgrEA+EMEv+Od9ElKlHwGKmmvQ=="], - "react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - "react-native/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - "react-native/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], - - "react-native/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], - - "react-native-reanimated/react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.1.7", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-EH6i7E8epJGIcu7KpfXYXiV2JFIYITtq+rVS8uEb+92naMRBdxhTuS8Wn2Q7j9sqyO0B+Xbaaf9VdipIAmGW4w=="], + "react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="], "react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="], + "react-native-worklets/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "requireg/resolve": ["resolve@1.7.1", "", { "dependencies": { "path-parse": "^1.0.5" } }, "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw=="], @@ -2341,13 +2314,9 @@ "@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], - "@expo/cli/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "@expo/metro/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="], - "@expo/cli/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - - "@expo/cli/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - - "@expo/package-manager/@expo/json-file/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], + "@expo/metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="], "@expo/package-manager/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], @@ -2367,27 +2336,7 @@ "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "@jest/environment/jest-mock/jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], - - "@jest/fake-timers/jest-message-util/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], - - "@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], - - "@react-native-community/cli-server-api/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], - - "@react-native-community/cli-server-api/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - - "@react-native-community/cli-server-api/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - - "@react-native/community-cli-plugin/@react-native/dev-middleware/@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.79.5", "", {}, "sha512-WQ49TRpCwhgUYo5/n+6GGykXmnumpOkl4Lr2l2o2buWU9qPOwoiBqJAtmWEXsAug4ciw3eLiVfthn5ufs0VB0A=="], - - "@react-native/community-cli-plugin/@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], - - "@react-native/community-cli-plugin/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - - "@react-native/dev-middleware/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - - "@react-native/dev-middleware/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="], "@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -2413,8 +2362,6 @@ "ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "better-opn/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -2427,12 +2374,6 @@ "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "jest-validate/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], - - "jest-validate/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - - "jest-validate/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -2447,23 +2388,21 @@ "logkitty/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], - "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="], + "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="], - "metro-config/cosmiconfig/import-fresh": ["import-fresh@2.0.0", "", { "dependencies": { "caller-path": "^2.0.0", "resolve-from": "^3.0.0" } }, "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg=="], + "metro-transform-worker/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="], - "metro-config/cosmiconfig/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + "metro-transform-worker/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="], - "metro-config/cosmiconfig/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], + "metro/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="], - "metro/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="], + "metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="], "node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "react-native/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "patch-package/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], - "react-native/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - - "react-native/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], @@ -2497,8 +2436,6 @@ "@expo/cli/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], - "@expo/cli/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], - "@expo/package-manager/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "@expo/package-manager/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], @@ -2511,16 +2448,6 @@ "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - "@jest/fake-timers/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], - - "@jest/fake-timers/jest-message-util/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - - "@jest/fake-timers/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - - "@react-native-community/cli-server-api/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], - - "@react-native/community-cli-plugin/@react-native/dev-middleware/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - "@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -2541,8 +2468,6 @@ "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "jest-validate/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], - "log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -2553,12 +2478,6 @@ "logkitty/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], - "metro-config/cosmiconfig/import-fresh/resolve-from": ["resolve-from@3.0.0", "", {}, "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw=="], - - "metro-config/cosmiconfig/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - - "react-native/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], - "serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], @@ -2577,8 +2496,6 @@ "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - "@jest/fake-timers/jest-message-util/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], - "ansi-fragments/slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], diff --git a/components/AddToFavorites.tsx b/components/AddToFavorites.tsx index a00d20bb..c69a83df 100644 --- a/components/AddToFavorites.tsx +++ b/components/AddToFavorites.tsx @@ -1,6 +1,6 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { FC } from "react"; -import { Platform, View, type ViewProps } from "react-native"; +import { View, type ViewProps } from "react-native"; import { RoundButton } from "@/components/RoundButton"; import { useFavorite } from "@/hooks/useFavorite"; @@ -11,24 +11,11 @@ interface Props extends ViewProps { export const AddToFavorites: FC = ({ item, ...props }) => { const { isFavorite, toggleFavorite } = useFavorite(item); - if (Platform.OS === "ios") { - return ( - - - - ); - } - return ( diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index e8228c86..3a3f9726 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -1,11 +1,9 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; -import { useMemo } from "react"; -import { Platform, TouchableOpacity, View } from "react-native"; - -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { Platform, TouchableOpacity, View } from "react-native"; import { Text } from "./common/Text"; +import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown"; interface Props extends React.ComponentProps { source?: MediaSourceInfo; @@ -20,6 +18,8 @@ export const AudioTrackSelector: React.FC = ({ ...props }) => { const isTv = Platform.isTV; + const [open, setOpen] = useState(false); + const { t } = useTranslation(); const audioStreams = useMemo( () => source?.MediaStreams?.filter((x) => x.Type === "Audio"), @@ -31,55 +31,58 @@ export const AudioTrackSelector: React.FC = ({ [audioStreams, selected], ); - const { t } = useTranslation(); + const optionGroups: OptionGroup[] = useMemo( + () => [ + { + options: + audioStreams?.map((audio, idx) => ({ + type: "radio" as const, + label: audio.DisplayTitle || `Audio Stream ${idx + 1}`, + value: audio.Index ?? idx, + selected: audio.Index === selected, + onPress: () => { + if (audio.Index !== null && audio.Index !== undefined) { + onChange(audio.Index); + } + }, + })) || [], + }, + ], + [audioStreams, selected, onChange], + ); + + const handleOptionSelect = () => { + setOpen(false); + }; + + const trigger = ( + + {t("item_card.audio")} + setOpen(true)} + > + {selectedAudioSteam?.DisplayTitle} + + + ); if (isTv) return null; return ( - - - - - - {t("item_card.audio")} - - - - {selectedAudioSteam?.DisplayTitle} - - - - - - Audio streams - {audioStreams?.map((audio, idx: number) => ( - { - if (audio.Index !== null && audio.Index !== undefined) - onChange(audio.Index); - }} - > - - {audio.DisplayTitle} - - - ))} - - - + bottomSheetConfig={{ + enablePanDownToClose: true, + }} + /> ); }; diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index d52a6bf6..26d6e2c4 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -1,10 +1,8 @@ -import { Platform, TouchableOpacity, View } from "react-native"; - -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { Platform, TouchableOpacity, View } from "react-native"; import { Text } from "./common/Text"; +import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown"; export type Bitrate = { key: string; @@ -61,6 +59,8 @@ export const BitrateSelector: React.FC = ({ ...props }) => { const isTv = Platform.isTV; + const [open, setOpen] = useState(false); + const { t } = useTranslation(); const sorted = useMemo(() => { if (inverted) @@ -76,53 +76,59 @@ export const BitrateSelector: React.FC = ({ ); }, [inverted]); - const { t } = useTranslation(); + const optionGroups: OptionGroup[] = useMemo( + () => [ + { + options: sorted.map((bitrate) => ({ + type: "radio" as const, + label: bitrate.key, + value: bitrate, + selected: bitrate.value === selected?.value, + onPress: () => onChange(bitrate), + })), + }, + ], + [sorted, selected, onChange], + ); + + const handleOptionSelect = (optionId: string) => { + const selectedBitrate = sorted.find((b) => b.key === optionId); + if (selectedBitrate) { + onChange(selectedBitrate); + } + setOpen(false); + }; + + const trigger = ( + + {t("item_card.quality")} + setOpen(true)} + > + + {BITRATES.find((b) => b.value === selected?.value)?.key} + + + + ); if (isTv) return null; return ( - - - - - - {t("item_card.quality")} - - - - {BITRATES.find((b) => b.value === selected?.value)?.key} - - - - - - Bitrates - {sorted.map((b) => ( - { - onChange(b); - }} - > - {b.key} - - ))} - - - + bottomSheetConfig={{ + enablePanDownToClose: true, + }} + /> ); }; diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 80ead77a..fe34d7c3 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -64,9 +64,8 @@ export const DownloadItems: React.FC = ({ const { settings } = useSettings(); const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false); - const { processes, startBackgroundDownload, getDownloadedItems } = - useDownload(); - const downloadedFiles = getDownloadedItems(); + const { processes, startBackgroundDownload, downloadedItems } = useDownload(); + const downloadedFiles = downloadedItems; const [selectedOptions, setSelectedOptions] = useState< SelectedOptions | undefined @@ -90,11 +89,8 @@ export const DownloadItems: React.FC = ({ bottomSheetModalRef.current?.present(); }, []); - const handleSheetChanges = useCallback((index: number) => { - // Ensure modal is fully dismissed when index is -1 - if (index === -1) { - // Modal is fully closed - } + const handleSheetChanges = useCallback((_index: number) => { + // Modal state tracking handled by BottomSheetModal }, []); const closeModal = useCallback(() => { @@ -157,6 +153,13 @@ export const DownloadItems: React.FC = ({ itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id)) ); }, [queue, itemsNotDownloaded]); + + const itemsInProgressOrQueued = useMemo(() => { + const inProgress = itemsProcesses.length; + const inQueue = queue.filter((q) => itemIds.includes(q.item.Id)).length; + return inProgress + inQueue; + }, [itemsProcesses, queue, itemIds]); + const navigateToDownloads = () => router.push("/downloads"); const onDownloadedPress = () => { @@ -256,13 +259,12 @@ export const DownloadItems: React.FC = ({ throw new Error("No item id"); } - // Ensure modal is dismissed before starting download - await closeModal(); + closeModal(); - // Small delay to ensure modal is fully dismissed - setTimeout(() => { + // Wait for modal dismiss animation to complete + requestAnimationFrame(() => { initiateDownload(...itemsToDownload); - }, 100); + }); } else { toast.error( t("home.downloads.toasts.you_are_not_allowed_to_download_files"), @@ -282,7 +284,14 @@ export const DownloadItems: React.FC = ({ ); const renderButtonContent = () => { - if (processes.length > 0 && itemsProcesses.length > 0) { + // For single item downloads, show progress if item is being processed + // For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued + const shouldShowProgress = + itemIds.length === 1 + ? itemsProcesses.length > 0 + : itemsInProgressOrQueued > 1; + + if (processes.length > 0 && shouldShowProgress) { return progress === 0 ? ( ) : ( @@ -336,9 +345,6 @@ export const DownloadItems: React.FC = ({ backgroundColor: "#171717", }} onChange={handleSheetChanges} - onDismiss={() => { - // Ensure any pending state is cleared when modal is dismissed - }} backdropComponent={renderBackdrop} enablePanDownToClose enableDismissOnClose @@ -359,16 +365,18 @@ export const DownloadItems: React.FC = ({ })} - - - setSelectedOptions( - (prev) => prev && { ...prev, bitrate: val }, - ) - } - selected={selectedOptions?.bitrate} - /> + + + + setSelectedOptions( + (prev) => prev && { ...prev, bitrate: val }, + ) + } + selected={selectedOptions?.bitrate} + /> + {itemsNotDownloaded.length > 1 && ( {t("item_card.download.download_unwatched_only")} @@ -380,21 +388,23 @@ export const DownloadItems: React.FC = ({ )} {itemsNotDownloaded.length === 1 && ( - - setSelectedOptions( - (prev) => - prev && { - ...prev, - mediaSource: val, - }, - ) - } - selected={selectedOptions?.mediaSource} - /> + + + setSelectedOptions( + (prev) => + prev && { + ...prev, + mediaSource: val, + }, + ) + } + selected={selectedOptions?.mediaSource} + /> + {selectedOptions?.mediaSource && ( - + { @@ -427,11 +437,7 @@ export const DownloadItems: React.FC = ({ )} - diff --git a/components/ExampleGlobalModalUsage.tsx b/components/ExampleGlobalModalUsage.tsx new file mode 100644 index 00000000..ccebb823 --- /dev/null +++ b/components/ExampleGlobalModalUsage.tsx @@ -0,0 +1,203 @@ +/** + * Example Usage of Global Modal + * + * This file demonstrates how to use the global modal system from anywhere in your app. + * You can delete this file after understanding how it works. + */ + +import { Ionicons } from "@expo/vector-icons"; +import { TouchableOpacity, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useGlobalModal } from "@/providers/GlobalModalProvider"; + +/** + * Example 1: Simple Content Modal + */ +export const SimpleModalExample = () => { + const { showModal } = useGlobalModal(); + + const handleOpenModal = () => { + showModal( + + Simple Modal + + This is a simple modal with just some text content. + + + Swipe down or tap outside to close. + + , + ); + }; + + return ( + + Open Simple Modal + + ); +}; + +/** + * Example 2: Modal with Custom Snap Points + */ +export const CustomSnapPointsExample = () => { + const { showModal } = useGlobalModal(); + + const handleOpenModal = () => { + showModal( + + + Custom Snap Points + + + This modal has custom snap points (25%, 50%, 90%). + + + + Try dragging the modal to different heights! + + + , + { + snapPoints: ["25%", "50%", "90%"], + enableDynamicSizing: false, + }, + ); + }; + + return ( + + Custom Snap Points + + ); +}; + +/** + * Example 3: Complex Component in Modal + */ +const SettingsModalContent = () => { + const { hideModal } = useGlobalModal(); + + const settings = [ + { + id: 1, + title: "Notifications", + icon: "notifications-outline" as const, + enabled: true, + }, + { id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true }, + { + id: 3, + title: "Auto-play", + icon: "play-outline" as const, + enabled: false, + }, + ]; + + return ( + + Settings + + {settings.map((setting, index) => ( + + + + {setting.title} + + + + + + ))} + + + Close + + + ); +}; + +export const ComplexModalExample = () => { + const { showModal } = useGlobalModal(); + + const handleOpenModal = () => { + showModal(); + }; + + return ( + + Complex Component + + ); +}; + +/** + * Example 4: Modal Triggered from Function (e.g., API response) + */ +export const useShowSuccessModal = () => { + const { showModal } = useGlobalModal(); + + return (message: string) => { + showModal( + + + + + Success! + {message} + , + ); + }; +}; + +/** + * Main Demo Component + */ +export const GlobalModalDemo = () => { + const showSuccess = useShowSuccessModal(); + + return ( + + + Global Modal Examples + + + + + + + showSuccess("Operation completed successfully!")} + className='bg-orange-600 px-4 py-2 rounded-lg' + > + Show Success Modal + + + ); +}; diff --git a/components/GlobalModal.tsx b/components/GlobalModal.tsx new file mode 100644 index 00000000..361321d3 --- /dev/null +++ b/components/GlobalModal.tsx @@ -0,0 +1,73 @@ +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, +} from "@gorhom/bottom-sheet"; +import { useCallback } from "react"; +import { useGlobalModal } from "@/providers/GlobalModalProvider"; + +/** + * GlobalModal Component + * + * This component renders a global bottom sheet modal that can be controlled + * from anywhere in the app using the useGlobalModal hook. + * + * Place this component at the root level of your app (in _layout.tsx) + * after BottomSheetModalProvider. + */ +export const GlobalModal = () => { + const { hideModal, modalState, modalRef } = useGlobalModal(); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index === -1) { + hideModal(); + } + }, + [hideModal], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const defaultOptions = { + enableDynamicSizing: true, + enablePanDownToClose: true, + backgroundStyle: { + backgroundColor: "#171717", + }, + handleIndicatorStyle: { + backgroundColor: "white", + }, + }; + + // Merge default options with provided options + const modalOptions = { ...defaultOptions, ...modalState.options }; + + return ( + + {modalState.content} + + ); +}; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 070b02e2..d66a7e5c 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -204,7 +204,7 @@ export const ItemContent: React.FC = React.memo( {item.Type !== "Program" && !Platform.isTV && !isOffline && ( - + diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index 3125f654..70f397f6 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -2,13 +2,11 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { useCallback, useMemo } from "react"; -import { Platform, TouchableOpacity, View } from "react-native"; - -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - +import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { Platform, TouchableOpacity, View } from "react-native"; import { Text } from "./common/Text"; +import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown"; interface Props extends React.ComponentProps { item: BaseItemDto; @@ -23,7 +21,7 @@ export const MediaSourceSelector: React.FC = ({ ...props }) => { const isTv = Platform.isTV; - + const [open, setOpen] = useState(false); const { t } = useTranslation(); const getDisplayName = useCallback((source: MediaSourceInfo) => { @@ -46,50 +44,60 @@ export const MediaSourceSelector: React.FC = ({ return getDisplayName(selected); }, [selected, getDisplayName]); + const optionGroups: OptionGroup[] = useMemo( + () => [ + { + options: + item.MediaSources?.map((source) => ({ + type: "radio" as const, + label: getDisplayName(source), + value: source, + selected: source.Id === selected?.Id, + onPress: () => onChange(source), + })) || [], + }, + ], + [item.MediaSources, selected, getDisplayName, onChange], + ); + + const handleOptionSelect = (optionId: string) => { + const selectedSource = item.MediaSources?.find( + (source, idx) => `${source.Id || idx}` === optionId, + ); + if (selectedSource) { + onChange(selectedSource); + } + setOpen(false); + }; + + const trigger = ( + + {t("item_card.video")} + setOpen(true)} + > + {selectedName} + + + ); + if (isTv) return null; return ( - - - - - - {t("item_card.video")} - - - {selectedName} - - - - - Media sources - {item.MediaSources?.map((source, idx: number) => ( - { - onChange(source); - }} - > - - {getDisplayName(source)} - - - ))} - - - + bottomSheetConfig={{ + enablePanDownToClose: true, + }} + /> ); }; diff --git a/components/ParallaxPage.tsx b/components/ParallaxPage.tsx index 21b0540a..2f5d9adb 100644 --- a/components/ParallaxPage.tsx +++ b/components/ParallaxPage.tsx @@ -99,7 +99,7 @@ export const ParallaxScrollView: React.FC> = ({ style={{ top: -50, }} - className='relative flex-1 bg-transparent pb-24' + className='relative flex-1 bg-transparent pb-4' > = { + type: "radio"; + label: string; + value: T; + selected: boolean; + onPress: () => void; + disabled?: boolean; +}; + +export type ToggleOption = { + type: "toggle"; + label: string; + value: boolean; + onToggle: () => void; + disabled?: boolean; +}; + +export type Option = RadioOption | ToggleOption; + +// Option group structure +export type OptionGroup = { + title?: string; + options: Option[]; +}; + +interface PlatformDropdownProps { + trigger?: React.ReactNode; + title?: string; + groups: OptionGroup[]; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onOptionSelect?: (value?: any) => void; + expoUIConfig?: { + hostStyle?: any; + }; + bottomSheetConfig?: { + enableDynamicSizing?: boolean; + enablePanDownToClose?: boolean; + }; +} + +const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => ( + + + +); + +const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({ + option, + isLast, +}) => { + const isToggle = option.type === "toggle"; + const handlePress = isToggle ? option.onToggle : option.onPress; + + return ( + <> + + {option.label} + {isToggle ? ( + + ) : option.selected ? ( + + ) : ( + + )} + + {!isLast && ( + + )} + + ); +}; + +const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => ( + + {group.title && ( + + {group.title} + + )} + + {group.options.map((option, index) => ( + + ))} + + +); + +const BottomSheetContent: React.FC<{ + title?: string; + groups: OptionGroup[]; + onOptionSelect?: (value?: any) => void; + onClose?: () => void; +}> = ({ title, groups, onOptionSelect, onClose }) => { + const insets = useSafeAreaInsets(); + + // Wrap the groups to call onOptionSelect when an option is pressed + const wrappedGroups = groups.map((group) => ({ + ...group, + options: group.options.map((option) => { + if (option.type === "radio") { + return { + ...option, + onPress: () => { + option.onPress(); + onOptionSelect?.(option.value); + onClose?.(); + }, + }; + } + if (option.type === "toggle") { + return { + ...option, + onToggle: () => { + option.onToggle(); + onOptionSelect?.(option.value); + }, + }; + } + return option; + }), + })); + + return ( + + {title && {title}} + {wrappedGroups.map((group, index) => ( + + ))} + + ); +}; + +const PlatformDropdownComponent = ({ + trigger, + title, + groups, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + onOptionSelect, + expoUIConfig, + bottomSheetConfig, +}: PlatformDropdownProps) => { + const { showModal, hideModal } = useGlobalModal(); + + // Use internal state if not controlled externally + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; + const onOpenChange = controlledOnOpenChange ?? setInternalOpen; + + // Handle open/close state changes for Android + useEffect(() => { + if (Platform.OS === "android" && open === true) { + showModal( + { + hideModal(); + onOpenChange?.(false); + }} + />, + { + snapPoints: ["90%"], + enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true, + }, + ); + } + }, [ + open, + title, + groups, + onOptionSelect, + onOpenChange, + bottomSheetConfig, + showModal, + hideModal, + ]); + + if (Platform.OS === "ios") { + return ( + + + + + {trigger || } + + + + {groups.flatMap((group, groupIndex) => { + // Check if this group has radio options + const radioOptions = group.options.filter( + (opt) => opt.type === "radio", + ) as RadioOption[]; + const toggleOptions = group.options.filter( + (opt) => opt.type === "toggle", + ) as ToggleOption[]; + + const items = []; + + // Add Picker for radio options ONLY if there's a group title + // Otherwise render as individual buttons + if (radioOptions.length > 0) { + if (group.title) { + // Use Picker for grouped options + items.push( + opt.label)} + variant='menu' + selectedIndex={radioOptions.findIndex( + (opt) => opt.selected, + )} + onOptionSelected={(event: any) => { + const index = event.nativeEvent.index; + const selectedOption = radioOptions[index]; + selectedOption?.onPress(); + onOptionSelect?.(selectedOption?.value); + }} + />, + ); + } else { + // Render radio options as direct buttons + radioOptions.forEach((option, optionIndex) => { + items.push( + , + ); + }); + } + } + + // Add Buttons for toggle options + toggleOptions.forEach((option, optionIndex) => { + items.push( + , + ); + }); + + return items; + })} + + + + ); + } + + // Android: Wrap trigger in TouchableOpacity to handle press events + // The useEffect above watches for open state changes and shows/hides the modal + return ( + onOpenChange(true)} activeOpacity={0.7}> + {trigger || Open Menu} + + ); +}; + +// Memoize to prevent unnecessary re-renders when parent re-renders +export const PlatformDropdown = React.memo( + PlatformDropdownComponent, + (prevProps, nextProps) => { + // Custom comparison - only re-render if these props actually change + return ( + prevProps.title === nextProps.title && + prevProps.open === nextProps.open && + prevProps.groups === nextProps.groups && // Reference equality (works because we memoize groups in caller) + prevProps.trigger === nextProps.trigger // Reference equality + ); + }, +); diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 6ac1956e..62e626db 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -1,11 +1,12 @@ import { useActionSheet } from "@expo/react-native-action-sheet"; +import { Button, Host } from "@expo/ui/swift-ui"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { useRouter } from "expo-router"; import { useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Alert, TouchableOpacity, View } from "react-native"; +import { Alert, Platform, TouchableOpacity, View } from "react-native"; import CastContext, { CastButton, PlayServicesState, @@ -33,10 +34,9 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { chromecast } from "@/utils/profiles/chromecast"; import { chromecasth265 } from "@/utils/profiles/chromecasth265"; import { runtimeTicksToMinutes } from "@/utils/time"; -import type { Button } from "./Button"; import type { SelectedOptions } from "./ItemContent"; -interface Props extends React.ComponentProps { +interface Props extends React.ComponentProps { item: BaseItemDto; selectedOptions: SelectedOptions; isOffline?: boolean; @@ -165,7 +165,7 @@ export const PlayButton: React.FC = ({ api, item, deviceProfile: enableH265 ? chromecasth265 : chromecast, - startTimeTicks: item?.UserData?.PlaybackPositionTicks!, + startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0, userId: user.Id, audioStreamIndex: selectedOptions.audioIndex, maxStreamingBitrate: selectedOptions.bitrate?.value, @@ -364,6 +364,46 @@ export const PlayButton: React.FC = ({ * ********************* */ + if (Platform.OS === "ios") + return ( + + + + ); + return ( = ({ accessibilityHint='Tap to play the media' onPress={onPress} className={"relative"} - {...props} > = ({ items, ...props }) => { const allPlayed = items.every((item) => item.UserData?.Played); const toggle = useMarkAsPlayed(items); - if (Platform.OS === "ios") { - return ( - - { - await toggle(!allPlayed); - }} - size={props.size} - /> - - ); - } - return ( { await toggle(!allPlayed); diff --git a/components/RoundButton.tsx b/components/RoundButton.tsx index caa86c04..5d490f19 100644 --- a/components/RoundButton.tsx +++ b/components/RoundButton.tsx @@ -96,7 +96,7 @@ export const RoundButton: React.FC> = ({ diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index de3f4697..6fca1955 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -1,12 +1,10 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Platform, TouchableOpacity, View } from "react-native"; import { tc } from "@/utils/textTools"; - -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - -import { useTranslation } from "react-i18next"; import { Text } from "./common/Text"; +import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown"; interface Props extends React.ComponentProps { source?: MediaSourceInfo; @@ -21,6 +19,8 @@ export const SubtitleTrackSelector: React.FC = ({ ...props }) => { const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const subtitleStreams = useMemo(() => { return source?.MediaStreams?.filter((x) => x.Type === "Subtitle"); }, [source]); @@ -30,64 +30,83 @@ export const SubtitleTrackSelector: React.FC = ({ [subtitleStreams, selected], ); + const optionGroups: OptionGroup[] = useMemo(() => { + const options = [ + { + type: "radio" as const, + label: t("item_card.none"), + value: -1, + selected: selected === -1, + onPress: () => onChange(-1), + }, + ...(subtitleStreams?.map((subtitle, idx) => ({ + type: "radio" as const, + label: subtitle.DisplayTitle || `Subtitle Stream ${idx + 1}`, + value: subtitle.Index, + selected: subtitle.Index === selected, + onPress: () => onChange(subtitle.Index ?? -1), + })) || []), + ]; + + return [ + { + options, + }, + ]; + }, [subtitleStreams, selected, t, onChange]); + + const handleOptionSelect = (optionId: string) => { + if (optionId === "none") { + onChange(-1); + } else { + const selectedStream = subtitleStreams?.find( + (subtitle, idx) => `${subtitle.Index || idx}` === optionId, + ); + if ( + selectedStream && + selectedStream.Index !== undefined && + selectedStream.Index !== null + ) { + onChange(selectedStream.Index); + } + } + setOpen(false); + }; + + const trigger = ( + + + {t("item_card.subtitles")} + + setOpen(true)} + > + + {selectedSubtitleSteam + ? tc(selectedSubtitleSteam?.DisplayTitle, 7) + : t("item_card.none")} + + + + ); + if (Platform.isTV || subtitleStreams?.length === 0) return null; return ( - - - - - - {t("item_card.subtitles")} - - - - {selectedSubtitleSteam - ? tc(selectedSubtitleSteam?.DisplayTitle, 7) - : t("item_card.none")} - - - - - - Subtitle tracks - { - onChange(-1); - }} - > - None - - {subtitleStreams?.map((subtitle, idx: number) => ( - { - if (subtitle.Index !== undefined && subtitle.Index !== null) - onChange(subtitle.Index); - }} - > - - {subtitle.DisplayTitle} - - - ))} - - - + bottomSheetConfig={{ + enablePanDownToClose: true, + }} + /> ); }; diff --git a/components/AppleTVCarousel.tsx b/components/apple-tv-carousel/AppleTVCarousel.tsx similarity index 87% rename from components/AppleTVCarousel.tsx rename to components/apple-tv-carousel/AppleTVCarousel.tsx index 4f9ee1c3..c30711e8 100644 --- a/components/AppleTVCarousel.tsx +++ b/components/apple-tv-carousel/AppleTVCarousel.tsx @@ -28,15 +28,16 @@ import Animated, { } from "react-native-reanimated"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; +import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; -import { ItemImage } from "./common/ItemImage"; -import { getItemNavigation } from "./common/TouchableItemRouter"; -import type { SelectedOptions } from "./ItemContent"; -import { PlayButton } from "./PlayButton"; -import { PlayedStatus } from "./PlayedStatus"; +import { ItemImage } from "../common/ItemImage"; +import { getItemNavigation } from "../common/TouchableItemRouter"; +import type { SelectedOptions } from "../ItemContent"; +import { PlayButton } from "../PlayButton"; +import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton"; interface AppleTVCarouselProps { initialIndex?: number; @@ -50,10 +51,11 @@ const GRADIENT_HEIGHT_BOTTOM = 150; const LOGO_HEIGHT = 80; // Position Constants -const LOGO_BOTTOM_POSITION = 210; -const GENRES_BOTTOM_POSITION = 170; -const CONTROLS_BOTTOM_POSITION = 100; -const DOTS_BOTTOM_POSITION = 60; +const LOGO_BOTTOM_POSITION = 260; +const GENRES_BOTTOM_POSITION = 220; +const OVERVIEW_BOTTOM_POSITION = 165; +const CONTROLS_BOTTOM_POSITION = 80; +const DOTS_BOTTOM_POSITION = 40; // Size Constants const DOT_HEIGHT = 6; @@ -63,13 +65,15 @@ const PLAY_BUTTON_SKELETON_HEIGHT = 50; const PLAYED_STATUS_SKELETON_SIZE = 40; const TEXT_SKELETON_HEIGHT = 20; const TEXT_SKELETON_WIDTH = 250; +const OVERVIEW_SKELETON_HEIGHT = 16; +const OVERVIEW_SKELETON_WIDTH = 400; const _EMPTY_STATE_ICON_SIZE = 64; // Spacing Constants const HORIZONTAL_PADDING = 40; const DOT_PADDING = 2; const DOT_GAP = 4; -const CONTROLS_GAP = 20; +const CONTROLS_GAP = 10; const _TEXT_MARGIN_TOP = 16; // Border Radius Constants @@ -88,13 +92,16 @@ const VELOCITY_THRESHOLD = 400; // Text Constants const GENRES_FONT_SIZE = 16; +const OVERVIEW_FONT_SIZE = 14; const _EMPTY_STATE_FONT_SIZE = 18; const TEXT_SHADOW_RADIUS = 2; const MAX_GENRES_COUNT = 2; const MAX_BUTTON_WIDTH = 300; +const OVERVIEW_MAX_LINES = 2; +const OVERVIEW_MAX_WIDTH = "80%"; // Opacity Constants -const OVERLAY_OPACITY = 0.4; +const OVERLAY_OPACITY = 0.3; const DOT_INACTIVE_OPACITY = 0.6; const TEXT_OPACITY = 0.9; @@ -180,7 +187,7 @@ export const AppleTVCarousel: React.FC = ({ userId: user.Id, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], includeItemTypes: ["Movie", "Series", "Episode"], - fields: ["Genres"], + fields: ["Genres", "Overview"], limit: 2, }); return response.data.Items || []; @@ -195,7 +202,7 @@ export const AppleTVCarousel: React.FC = ({ if (!api || !user?.Id) return []; const response = await getTvShowsApi(api).getNextUp({ userId: user.Id, - fields: ["MediaSourceCount", "Genres"], + fields: ["MediaSourceCount", "Genres", "Overview"], limit: 2, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableResumable: false, @@ -214,7 +221,7 @@ export const AppleTVCarousel: React.FC = ({ const response = await getUserLibraryApi(api).getLatestMedia({ userId: user.Id, limit: 2, - fields: ["PrimaryImageAspectRatio", "Path", "Genres"], + fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"], imageTypeLimit: 1, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], }); @@ -374,6 +381,8 @@ export const AppleTVCarousel: React.FC = ({ }; }); + const togglePlayedStatus = useMarkAsPlayed(items); + const headerAnimatedStyle = useAnimatedStyle(() => { if (!scrollOffset) return {}; return { @@ -521,6 +530,36 @@ export const AppleTVCarousel: React.FC = ({ /> + {/* Overview Skeleton */} + + + + + {/* Controls Skeleton */} = ({ + {/* Overview Section - for Episodes and Movies */} + {(item.Type === "Episode" || item.Type === "Movie") && + item.Overview && ( + + navigateToItem(item)}> + + {item.Overview} + + + + )} + {/* Controls Section */} = ({ {/* Mark as Played */} - + diff --git a/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx b/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx new file mode 100644 index 00000000..ea9bd98d --- /dev/null +++ b/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx @@ -0,0 +1,51 @@ +import { Button, Host } from "@expo/ui/swift-ui"; +import { Ionicons } from "@expo/vector-icons"; +import { Platform, View } from "react-native"; +import { RoundButton } from "../RoundButton"; + +interface MarkAsPlayedLargeButtonProps { + isPlayed: boolean; + onToggle: (isPlayed: boolean) => void; +} + +export const MarkAsPlayedLargeButton: React.FC< + MarkAsPlayedLargeButtonProps +> = ({ isPlayed, onToggle }) => { + if (Platform.OS === "ios") + return ( + + + + ); + + return ( + + onToggle(isPlayed)} + /> + + ); +}; diff --git a/components/common/Dropdown.tsx b/components/common/Dropdown.tsx deleted file mode 100644 index 3ec4d5ce..00000000 --- a/components/common/Dropdown.tsx +++ /dev/null @@ -1,125 +0,0 @@ -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - -import { - type PropsWithChildren, - type ReactNode, - useEffect, - useState, -} from "react"; -import { Platform, TouchableOpacity, View, type ViewProps } from "react-native"; -import { Text } from "@/components/common/Text"; -import DisabledSetting from "@/components/settings/DisabledSetting"; - -interface Props { - data: T[]; - disabled?: boolean; - placeholderText?: string; - keyExtractor: (item: T) => string; - titleExtractor: (item: T) => string | undefined; - title: string | ReactNode; - label: string; - onSelected: (...item: T[]) => void; - multiple?: boolean; -} - -const Dropdown = ({ - data, - disabled, - placeholderText, - keyExtractor, - titleExtractor, - title, - label, - onSelected, - multiple = false, - ...props -}: PropsWithChildren & ViewProps>) => { - const isTv = Platform.isTV; - - const [selected, setSelected] = useState(); - - useEffect(() => { - if (selected !== undefined) { - onSelected(...selected); - } - }, [selected, onSelected]); - - if (isTv) return null; - - return ( - - - - {typeof title === "string" ? ( - - {title} - - - {selected?.length !== undefined - ? selected.map(titleExtractor).join(",") - : placeholderText} - - - - ) : ( - title - )} - - - {label} - {data.map((item, _idx) => - multiple ? ( - keyExtractor(s) === keyExtractor(item)) - ? "on" - : "off" - } - key={keyExtractor(item)} - onValueChange={( - next: "on" | "off", - _previous: "on" | "off", - ) => { - setSelected((p) => { - const prev = p || []; - if (next === "on") { - return [...prev, item]; - } - return [ - ...prev.filter( - (p) => keyExtractor(p) !== keyExtractor(item), - ), - ]; - }); - }} - > - - {titleExtractor(item)} - - - ) : ( - setSelected([item])} - > - - {titleExtractor(item)} - - - ), - )} - - - - ); -}; - -export default Dropdown; diff --git a/components/common/HeaderBackButton.tsx b/components/common/HeaderBackButton.tsx index 3f75c83a..686cab5d 100644 --- a/components/common/HeaderBackButton.tsx +++ b/components/common/HeaderBackButton.tsx @@ -55,7 +55,7 @@ export const HeaderBackButton: React.FC = ({ return ( router.back()} - className=' bg-neutral-800/80 rounded-full p-2' + className=' rounded-full p-2' {...touchableOpacityProps} > = Partial & Pick; - export interface HorizontalScrollRef { scrollToIndex: (index: number, viewOffset: number) => void; } interface HorizontalScrollProps - extends PartialExcept< - Omit, "renderItem">, - "estimatedItemSize" - > { + extends Omit, "renderItem" | "estimatedItemSize" | "data"> { data?: T[] | null; renderItem: (item: T, index: number) => React.ReactNode; keyExtractor?: (item: T, index: number) => string; @@ -44,7 +39,7 @@ export const HorizontalScroll = ( ...restProps } = props; - const flashListRef = useRef>(null); + const flashListRef = useRef>>(null); useImperativeHandle(ref!, () => ({ scrollToIndex: (index: number, viewOffset: number) => { @@ -78,7 +73,6 @@ export const HorizontalScroll = ( extraData={extraData} renderItem={renderFlashListItem} horizontal - estimatedItemSize={200} showsHorizontalScrollIndicator={false} contentContainerStyle={{ paddingHorizontal: 16, diff --git a/components/common/JellyseerrItemRouter.tsx b/components/common/JellyseerrItemRouter.tsx index 8a0b059a..2fdaa801 100644 --- a/components/common/JellyseerrItemRouter.tsx +++ b/components/common/JellyseerrItemRouter.tsx @@ -1,14 +1,8 @@ import { useRouter, useSegments } from "expo-router"; import type React from "react"; -import { type PropsWithChildren, useCallback, useMemo } from "react"; +import { type PropsWithChildren } from "react"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; -import * as ContextMenu from "zeego/context-menu"; -import { useJellyseerr } from "@/hooks/useJellyseerr"; import { MediaType } from "@/utils/jellyseerr/server/constants/media"; -import { - hasPermission, - Permission, -} from "@/utils/jellyseerr/server/lib/permissions"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import type { @@ -38,90 +32,33 @@ export const TouchableJellyseerrRouter: React.FC> = ({ }) => { const router = useRouter(); const segments = useSegments(); - const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const from = (segments as string[])[2] || "(home)"; - const autoApprove = useMemo(() => { - return ( - jellyseerrUser && - hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, { - type: "or", - }) - ); - }, [jellyseerrApi, jellyseerrUser]); - - const request = useCallback(() => { - if (!result) return; - requestMedia(mediaTitle, { - mediaId: result.id, - mediaType, - }); - }, [jellyseerrApi, result]); - if (from === "(home)" || from === "(search)" || from === "(libraries)") return ( - - - { - if (!result) return; + { + if (!result) return; - router.push({ - pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, - // @ts-expect-error - params: { - ...result, - mediaTitle, - releaseYear, - canRequest: canRequest.toString(), - posterSrc, - mediaType, - }, - }); - }} - {...props} - > - {children} - - - - Actions - {canRequest && mediaType === MediaType.MOVIE && ( - { - if (autoApprove) { - request(); - } - }} - shouldDismissMenuOnSelect - > - - Request - - - - )} - - + router.push({ + pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, + // @ts-expect-error + params: { + ...result, + mediaTitle, + releaseYear, + canRequest: canRequest.toString(), + posterSrc, + mediaType, + }, + }); + }} + {...props} + > + {children} + ); + + return null; }; diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 97b197c4..3dba2418 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -26,7 +26,7 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) { {t("home.downloads.active_downloads")} - + {processes?.map((p: JobStatus) => ( ))} diff --git a/components/downloads/DownloadCard.tsx b/components/downloads/DownloadCard.tsx index b90c4836..3f994a16 100644 --- a/components/downloads/DownloadCard.tsx +++ b/components/downloads/DownloadCard.tsx @@ -6,7 +6,6 @@ import { t } from "i18next"; import { useMemo } from "react"; import { ActivityIndicator, - Platform, TouchableOpacity, type TouchableOpacityProps, View, @@ -14,49 +13,36 @@ import { import { toast } from "sonner-native"; import { Text } from "@/components/common/Text"; import { useDownload } from "@/providers/DownloadProvider"; +import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator"; import { JobStatus } from "@/providers/Downloads/types"; +import { estimateDownloadSize } from "@/utils/download"; import { storage } from "@/utils/mmkv"; import { formatTimeString } from "@/utils/time"; -import { Button } from "../Button"; const bytesToMB = (bytes: number) => { return bytes / 1024 / 1024; }; +const formatBytes = (bytes: number): string => { + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; +}; + interface DownloadCardProps extends TouchableOpacityProps { process: JobStatus; } export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { - const { startDownload, pauseDownload, resumeDownload, removeProcess } = - useDownload(); + const { cancelDownload } = useDownload(); const router = useRouter(); const queryClient = useQueryClient(); - const handlePause = async (id: string) => { - try { - await pauseDownload(id); - toast.success(t("home.downloads.toasts.download_paused")); - } catch (error) { - console.error("Error pausing download:", error); - toast.error(t("home.downloads.toasts.could_not_pause_download")); - } - }; - - const handleResume = async (id: string) => { - try { - await resumeDownload(id); - toast.success(t("home.downloads.toasts.download_resumed")); - } catch (error) { - console.error("Error resuming download:", error); - toast.error(t("home.downloads.toasts.could_not_resume_download")); - } - }; - const handleDelete = async (id: string) => { try { - await removeProcess(id); - toast.success(t("home.downloads.toasts.download_deleted")); + await cancelDownload(id); + // cancelDownload already shows a toast, so don't show another one queryClient.invalidateQueries({ queryKey: ["downloads"] }); } catch (error) { console.error("Error deleting download:", error); @@ -64,16 +50,48 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { } }; - const eta = (p: JobStatus) => { - if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null; + const eta = useMemo(() => { + if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) { + return null; + } - const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0); - if (bytesRemaining <= 0) return null; + const secondsRemaining = calculateSmoothedETA( + process.id, + process.bytesDownloaded, + process.estimatedTotalSizeBytes, + ); - const secondsRemaining = bytesRemaining / p.speed; + if (!secondsRemaining || secondsRemaining <= 0) { + return null; + } return formatTimeString(secondsRemaining, "s"); - }; + }, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]); + + const estimatedSize = useMemo(() => { + if (process.estimatedTotalSizeBytes) return process.estimatedTotalSizeBytes; + + // Calculate from bitrate + duration (only if bitrate value is defined) + if (process.maxBitrate.value) { + return estimateDownloadSize( + process.maxBitrate.value, + process.item.RunTimeTicks, + ); + } + + return undefined; + }, [ + process.maxBitrate.value, + process.item.RunTimeTicks, + process.estimatedTotalSizeBytes, + ]); + + const isTranscoding = process.isTranscoding || false; + + const downloadedAmount = useMemo(() => { + if (!process.bytesDownloaded) return null; + return formatBytes(process.bytesDownloaded); + }, [process.bytesDownloaded]); const base64Image = useMemo(() => { return storage.getString(process.item.Id!); @@ -98,9 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { > {process.status === "downloading" && ( 0 @@ -111,26 +127,10 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { )} {/* Action buttons in bottom right corner */} - - {process.status === "downloading" && Platform.OS !== "ios" && ( - handlePause(process.id)} - className='p-1' - > - - - )} - {process.status === "paused" && Platform.OS !== "ios" && ( - handleResume(process.id)} - className='p-1' - > - - - )} + handleDelete(process.id)} - className='p-1' + className='p-2 bg-neutral-800 rounded-full' > @@ -152,47 +152,53 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { /> )} - + {process.item.Type} {process.item.Name} {process.item.ProductionYear} - + + {isTranscoding && ( + + Transcoding + + )} + + {/* Row 1: Progress + Downloaded/Total */} + {sanitizedProgress === 0 ? ( ) : ( - {sanitizedProgress.toFixed(0)}% - )} - {process.speed && process.speed > 0 && ( - - {bytesToMB(process.speed).toFixed(2)} MB/s + + {sanitizedProgress.toFixed(0)}% )} - {eta(process) && ( - - {t("home.downloads.eta", { eta: eta(process) })} + {downloadedAmount && ( + + {downloadedAmount} + {estimatedSize + ? ` / ${isTranscoding ? "~" : ""}${formatBytes(estimatedSize)}` + : ""} )} - - {process.status} + {/* Row 2: Speed + ETA */} + + {process.speed && process.speed > 0 && ( + + {bytesToMB(process.speed).toFixed(2)} MB/s + + )} + {eta && ( + + {t("home.downloads.eta", { eta: eta })} + + )} - {process.status === "completed" && ( - - - - )} ); diff --git a/components/downloads/DownloadSize.tsx b/components/downloads/DownloadSize.tsx index c6316ff9..32993f37 100644 --- a/components/downloads/DownloadSize.tsx +++ b/components/downloads/DownloadSize.tsx @@ -13,14 +13,13 @@ export const DownloadSize: React.FC = ({ items, ...props }) => { - const { getDownloadedItemSize, getDownloadedItems } = useDownload(); - const downloadedFiles = getDownloadedItems(); + const { getDownloadedItemSize, downloadedItems } = useDownload(); const [size, setSize] = useState(); const itemIds = useMemo(() => items.map((i) => i.Id), [items]); useEffect(() => { - if (!downloadedFiles) return; + if (!downloadedItems) return; let s = 0; @@ -32,7 +31,7 @@ export const DownloadSize: React.FC = ({ } } setSize(s.bytesToReadable()); - }, [itemIds]); + }, [itemIds, downloadedItems, getDownloadedItemSize]); const sizeText = useMemo(() => { if (!size) return "..."; diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx index 83c0a4e7..3c57a17a 100644 --- a/components/downloads/EpisodeCard.tsx +++ b/components/downloads/EpisodeCard.tsx @@ -28,7 +28,7 @@ export const EpisodeCard: React.FC = ({ item }) => { */ const handleDeleteFile = useCallback(() => { if (item.Id) { - deleteFile(item.Id, "Episode"); + deleteFile(item.Id); successHapticFeedback(); } }, [deleteFile, item.Id]); diff --git a/components/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx index 8b2d4911..85b1a89e 100644 --- a/components/downloads/SeriesCard.tsx +++ b/components/downloads/SeriesCard.tsx @@ -19,7 +19,13 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => { return storage.getString(items[0].SeriesId!); }, []); - const deleteSeries = useCallback(async () => deleteItems(items), [items]); + const deleteSeries = useCallback( + async () => + deleteItems( + items.map((item) => item.Id).filter((id) => id !== undefined), + ), + [items], + ); const showActionSheet = useCallback(() => { const options = ["Delete", "Cancel"]; diff --git a/components/home/Home.tsx b/components/home/Home.tsx new file mode 100644 index 00000000..44298311 --- /dev/null +++ b/components/home/Home.tsx @@ -0,0 +1,515 @@ +import { Feather, Ionicons } from "@expo/vector-icons"; +import type { Api } from "@jellyfin/sdk"; +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, useRouter, 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, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; +import { Loader } from "@/components/Loader"; +import { MediaListSection } from "@/components/medialists/MediaListSection"; +import { Colors } from "@/constants/Colors"; +import { useNetworkStatus } from "@/hooks/useNetworkStatus"; +import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { eventBus } from "@/utils/eventBus"; + +type ScrollingCollectionListSection = { + type: "ScrollingCollectionList"; + title?: string; + queryKey: (string | undefined | null)[]; + queryFn: QueryFunction; + orientation?: "horizontal" | "vertical"; +}; + +type MediaListSectionType = { + type: "MediaListSection"; + queryKey: (string | undefined)[]; + queryFn: QueryFunction; +}; + +type Section = ScrollingCollectionListSection | 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(); + + 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); + 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", "Genres"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], + includeItemTypes, + parentId, + }) + ).data || [] + ); + }, + type: "ScrollingCollectionList", + }), + [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, + ); + }); + + const ss: Section[] = [ + { + title: t("home.continue_watching"), + queryKey: ["home", "resumeItems"], + queryFn: async () => + ( + await getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], + includeItemTypes: ["Movie", "Series", "Episode"], + fields: ["Genres"], + }) + ).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", "Genres"], + limit: 20, + enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], + enableResumable: false, + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + ...latestMediaViews, + { + 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, t, createCollectionConfig]); + + 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}`; + ss.push({ + title: t(`${id}`), + queryKey: ["home", "custom", String(index), section.title ?? null], + 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 || []; + } + if (section.nextUp) { + const response = await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + fields: ["MediaSourceCount", "Genres"], + limit: section.nextUp?.limit || 25, + enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], + enableResumable: section.nextUp?.enableResumable, + enableRewatching: section.nextUp?.enableRewatching, + }); + return response.data.Items || []; + } + if (section.latest) { + const response = await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + includeItemTypes: section.latest?.includeItemTypes, + limit: section.latest?.limit || 25, + isPlayed: section.latest?.isPlayed, + groupItems: section.latest?.groupItems, + }); + return response.data || []; + } + if (section.custom) { + const response = await api.get( + section.custom.endpoint, + { + params: { ...(section.custom.query || {}), userId: user?.Id }, + headers: section.custom.headers || {}, + }, + ); + return response.data.Items || []; + } + return []; + }, + type: "ScrollingCollectionList", + orientation: section?.orientation || "vertical", + }); + }); + return ss; + }, [api, user?.Id, settings?.home?.sections]); + + const sections = settings?.home?.sections ? customSections : defaultSections; + + 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) => { + if (section.type === "ScrollingCollectionList") { + return ( + + ); + } + if (section.type === "MediaListSection") { + return ( + + ); + } + return null; + })} + + + ); +}; + +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 ?? []; +} + +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/HomeIndex.tsx b/components/home/HomeWithCarousel.tsx similarity index 86% rename from components/settings/HomeIndex.tsx rename to components/home/HomeWithCarousel.tsx index 6fd24395..18b107fe 100644 --- a/components/settings/HomeIndex.tsx +++ b/components/home/HomeWithCarousel.tsx @@ -12,11 +12,7 @@ import { getUserLibraryApi, getUserViewsApi, } from "@jellyfin/sdk/lib/utils/api"; -import { - type QueryFunction, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; +import { type QueryFunction, useQuery } from "@tanstack/react-query"; import { useNavigation, useRouter, useSegments } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -24,7 +20,6 @@ import { useTranslation } from "react-i18next"; import { ActivityIndicator, Platform, - RefreshControl, TouchableOpacity, View, } from "react-native"; @@ -45,7 +40,7 @@ import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; -import { AppleTVCarousel } from "../AppleTVCarousel"; +import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel"; type ScrollingCollectionListSection = { type: "ScrollingCollectionList"; @@ -63,32 +58,19 @@ type MediaListSectionType = { type Section = ScrollingCollectionListSection | MediaListSectionType; -export const HomeIndex = () => { +export const HomeWithCarousel = () => { const router = useRouter(); - const { t } = useTranslation(); - const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); - const insets = useSafeAreaInsets(); - - const [loading, setLoading] = useState(false); + const [_loading, setLoading] = useState(false); const { settings, refreshStreamyfinPluginSettings } = useSettings(); - const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? true; - const queryClient = useQueryClient(); - const headerOverlayOffset = Platform.isTV - ? 0 - : showLargeHomeCarousel - ? 60 - : 0; - + const headerOverlayOffset = Platform.isTV ? 0 : 60; const navigation = useNavigation(); - const animatedScrollRef = useAnimatedRef(); const scrollOffset = useScrollViewOffset(animatedScrollRef); - - const { getDownloadedItems, cleanCacheDirectory } = useDownload(); + const { downloadedItems, cleanCacheDirectory } = useDownload(); const prevIsConnected = useRef(false); const { isConnected, @@ -97,15 +79,19 @@ export const HomeIndex = () => { retryCheck, } = useNetworkStatus(); const invalidateCache = useInvalidatePlaybackProgressCache(); + useEffect(() => { - // Only invalidate cache when transitioning from offline to online if (isConnected && !prevIsConnected.current) { invalidateCache(); } - // Update the ref to the current state for the next render prevIsConnected.current = isConnected; }, [isConnected, invalidateCache]); + const hasDownloads = useMemo(() => { + if (Platform.isTV) return false; + return downloadedItems.length > 0; + }, [downloadedItems]); + useEffect(() => { if (Platform.isTV) { navigation.setOptions({ @@ -113,7 +99,6 @@ export const HomeIndex = () => { }); return; } - const hasDownloads = getDownloadedItems().length > 0; navigation.setOptions({ headerLeft: () => ( { router.push("/(auth)/downloads"); }} className='ml-1.5' + style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > { ), }); - }, [navigation, router]); + }, [navigation, router, hasDownloads]); useEffect(() => { cleanCacheDirectory().catch((_e) => @@ -188,24 +174,13 @@ export const HomeIndex = () => { ); }, [userViews]); - const refetch = async () => { + const _refetch = async () => { setLoading(true); await refreshStreamyfinPluginSettings(); - await queryClient.clear(); await invalidateCache(); setLoading(false); }; - useEffect(() => { - const unsubscribe = eventBus.on("refreshHome", () => { - refetch(); - }); - - return () => { - unsubscribe(); - }; - }, [refetch]); - const createCollectionConfig = useCallback( ( title: string, @@ -236,7 +211,6 @@ export const HomeIndex = () => { [api, user?.Id], ); - // Always call useMemo() at the top-level, using computed dependencies for both "default"/custom sections const defaultSections = useMemo(() => { if (!api || !user?.Id) return []; @@ -246,10 +220,10 @@ export const HomeIndex = () => { ? [] : ["Movie"]; const title = t("home.recently_added_in", { libraryName: c.Name }); - const queryKey = [ + const queryKey: string[] = [ "home", `recentlyAddedIn${c.CollectionType}`, - user?.Id!, + user.Id!, c.Id!, ]; return createCollectionConfig( @@ -293,16 +267,6 @@ export const HomeIndex = () => { 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], @@ -411,15 +375,12 @@ export const HomeIndex = () => { let subtitle = ""; if (!isConnected) { - // No network connection title = t("home.no_internet"); subtitle = t("home.no_internet_message"); } else if (serverConnected === null) { - // Network is up, but server is being checked title = t("home.checking_server_connection"); subtitle = t("home.checking_server_connection_message"); } else if (!serverConnected) { - // Network is up, but server is unreachable title = t("home.server_unreachable"); subtitle = t("home.server_unreachable_message"); } @@ -488,35 +449,18 @@ export const HomeIndex = () => { nestedScrollEnabled contentInsetAdjustmentBehavior='never' scrollEventThrottle={16} - bounces={!showLargeHomeCarousel} - overScrollMode={showLargeHomeCarousel ? "never" : "auto"} - refreshControl={ - showLargeHomeCarousel ? undefined : ( - - ) - } + bounces={false} + overScrollMode='never' style={{ marginTop: -headerOverlayOffset }} contentContainerStyle={{ paddingTop: headerOverlayOffset }} > - {showLargeHomeCarousel && ( - - )} + @@ -551,7 +495,6 @@ export const HomeIndex = () => { ); }; -// Function to get suggestions async function getSuggestions(api: Api, userId: string | undefined) { if (!userId) return []; const response = await getSuggestionsApi(api).getSuggestions({ @@ -563,7 +506,6 @@ async function getSuggestions(api: Api, userId: string | undefined) { return response.data.Items ?? []; } -// Function to get the next up TV show for a series async function getNextUp( api: Api, userId: string | undefined, diff --git a/components/jellyseerr/Cast.tsx b/components/jellyseerr/Cast.tsx index 6abd890a..99440bd8 100644 --- a/components/jellyseerr/Cast.tsx +++ b/components/jellyseerr/Cast.tsx @@ -23,7 +23,6 @@ const CastSlide: React.FC< showsHorizontalScrollIndicator={false} data={details?.credits.cast} ItemSeparatorComponent={() => } - estimatedItemSize={15} keyExtractor={(item) => item?.id?.toString()} contentContainerStyle={{ paddingHorizontal: 16 }} renderItem={({ item }) => ( diff --git a/components/jellyseerr/RequestModal.tsx b/components/jellyseerr/RequestModal.tsx index 0dc521a7..36fff7ee 100644 --- a/components/jellyseerr/RequestModal.tsx +++ b/components/jellyseerr/RequestModal.tsx @@ -10,8 +10,8 @@ import { forwardRef, useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { View, type ViewProps } from "react-native"; import { Button } from "@/components/Button"; -import Dropdown from "@/components/common/Dropdown"; import { Text } from "@/components/common/Text"; +import { PlatformDropdown } from "@/components/PlatformDropdown"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import type { QualityProfile, @@ -48,8 +48,22 @@ const RequestModal = forwardRef< userId: jellyseerrUser?.id, }); + const [qualityProfileOpen, setQualityProfileOpen] = useState(false); + const [rootFolderOpen, setRootFolderOpen] = useState(false); + const [tagsOpen, setTagsOpen] = useState(false); + const [usersOpen, setUsersOpen] = useState(false); + const { t } = useTranslation(); + // Reset all dropdown states when modal closes + const handleDismiss = useCallback(() => { + setQualityProfileOpen(false); + setRootFolderOpen(false); + setTagsOpen(false); + setUsersOpen(false); + onDismiss?.(); + }, [onDismiss]); + const { data: serviceSettings } = useQuery({ queryKey: ["jellyseerr", "request", type, "service"], queryFn: async () => @@ -138,6 +152,109 @@ const RequestModal = forwardRef< }); }, [requestBody?.seasons]); + const pathTitleExtractor = (item: RootFolder) => + `${item.path} (${item.freeSpace.bytesToReadable()})`; + + const qualityProfileOptions = useMemo( + () => [ + { + options: + defaultServiceDetails?.profiles.map((profile) => ({ + type: "radio" as const, + label: profile.name, + value: profile.id.toString(), + selected: + (requestOverrides.profileId || defaultProfile?.id) === + profile.id, + onPress: () => + setRequestOverrides((prev) => ({ + ...prev, + profileId: profile.id, + })), + })) || [], + }, + ], + [ + defaultServiceDetails?.profiles, + defaultProfile, + requestOverrides.profileId, + ], + ); + + const rootFolderOptions = useMemo( + () => [ + { + options: + defaultServiceDetails?.rootFolders.map((folder) => ({ + type: "radio" as const, + label: pathTitleExtractor(folder), + value: folder.id.toString(), + selected: + (requestOverrides.rootFolder || defaultFolder?.path) === + folder.path, + onPress: () => + setRequestOverrides((prev) => ({ + ...prev, + rootFolder: folder.path, + })), + })) || [], + }, + ], + [ + defaultServiceDetails?.rootFolders, + defaultFolder, + requestOverrides.rootFolder, + ], + ); + + const tagsOptions = useMemo( + () => [ + { + options: + defaultServiceDetails?.tags.map((tag) => ({ + type: "toggle" as const, + label: tag.label, + value: + requestOverrides.tags?.includes(tag.id) || + defaultTags.some((dt) => dt.id === tag.id), + onToggle: () => + setRequestOverrides((prev) => { + const currentTags = prev.tags || defaultTags.map((t) => t.id); + const hasTag = currentTags.includes(tag.id); + return { + ...prev, + tags: hasTag + ? currentTags.filter((id) => id !== tag.id) + : [...currentTags, tag.id], + }; + }), + })) || [], + }, + ], + [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags], + ); + + const usersOptions = useMemo( + () => [ + { + options: + users?.map((user) => ({ + type: "radio" as const, + label: user.displayName, + value: user.id.toString(), + selected: + (requestOverrides.userId || jellyseerrUser?.id) === user.id, + onPress: () => + setRequestOverrides((prev) => ({ + ...prev, + userId: user.id, + })), + })) || [], + }, + ], + [users, jellyseerrUser, requestOverrides.userId], + ); + const request = useCallback(() => { const body = { is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k, @@ -163,15 +280,12 @@ const RequestModal = forwardRef< defaultTags, ]); - const pathTitleExtractor = (item: RootFolder) => - `${item.path} (${item.freeSpace.bytesToReadable()})`; - return ( )} + stackBehavior='push' > @@ -199,70 +314,112 @@ const RequestModal = forwardRef< {defaultService && defaultServiceDetails && users && ( <> - item.name} - placeholderText={ - requestOverrides.profileName || defaultProfile.name - } - keyExtractor={(item) => item.id.toString()} - label={t("jellyseerr.quality_profile")} - onSelected={(item) => - item && - setRequestOverrides((prev) => ({ - ...prev, - profileId: item?.id, - })) - } - title={t("jellyseerr.quality_profile")} - /> - item.id.toString()} - label={t("jellyseerr.root_folder")} - onSelected={(item) => - item && - setRequestOverrides((prev) => ({ - ...prev, - rootFolder: item.path, - })) - } - title={t("jellyseerr.root_folder")} - /> - item.label} - placeholderText={defaultTags.map((t) => t.label).join(",")} - keyExtractor={(item) => item.id.toString()} - label={t("jellyseerr.tags")} - onSelected={(...selected) => - setRequestOverrides((prev) => ({ - ...prev, - tags: selected.map((i) => i.id), - })) - } - title={t("jellyseerr.tags")} - /> - item.displayName} - placeholderText={jellyseerrUser!.displayName} - keyExtractor={(item) => item.id.toString() || ""} - label={t("jellyseerr.request_as")} - onSelected={(item) => - item && - setRequestOverrides((prev) => ({ - ...prev, - userId: item?.id, - })) - } - title={t("jellyseerr.request_as")} - /> + + + {t("jellyseerr.quality_profile")} + + + + {defaultServiceDetails.profiles.find( + (p) => + p.id === + (requestOverrides.profileId || + defaultProfile?.id), + )?.name || defaultProfile?.name} + + + } + title={t("jellyseerr.quality_profile")} + open={qualityProfileOpen} + onOpenChange={setQualityProfileOpen} + /> + + + + + {t("jellyseerr.root_folder")} + + + + {defaultServiceDetails.rootFolders.find( + (f) => + f.path === + (requestOverrides.rootFolder || + defaultFolder?.path), + ) + ? pathTitleExtractor( + defaultServiceDetails.rootFolders.find( + (f) => + f.path === + (requestOverrides.rootFolder || + defaultFolder?.path), + )!, + ) + : pathTitleExtractor(defaultFolder!)} + + + } + title={t("jellyseerr.root_folder")} + open={rootFolderOpen} + onOpenChange={setRootFolderOpen} + /> + + + + + {t("jellyseerr.tags")} + + + + {requestOverrides.tags + ? defaultServiceDetails.tags + .filter((t) => + requestOverrides.tags!.includes(t.id), + ) + .map((t) => t.label) + .join(", ") || + defaultTags.map((t) => t.label).join(", ") + : defaultTags.map((t) => t.label).join(", ")} + + + } + title={t("jellyseerr.tags")} + open={tagsOpen} + onOpenChange={setTagsOpen} + /> + + + + + {t("jellyseerr.request_as")} + + + + {users.find( + (u) => + u.id === + (requestOverrides.userId || jellyseerrUser?.id), + )?.displayName || jellyseerrUser!.displayName} + + + } + title={t("jellyseerr.request_as")} + open={usersOpen} + onOpenChange={setUsersOpen} + /> + )} diff --git a/components/search/DiscoverFilters.tsx b/components/search/DiscoverFilters.tsx new file mode 100644 index 00000000..2e844c88 --- /dev/null +++ b/components/search/DiscoverFilters.tsx @@ -0,0 +1,115 @@ +import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui"; +import { Platform, View } from "react-native"; +import { FilterButton } from "@/components/filters/FilterButton"; +import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage"; + +interface DiscoverFiltersProps { + searchFilterId: string; + orderFilterId: string; + jellyseerrOrderBy: JellyseerrSearchSort; + setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void; + jellyseerrSortOrder: "asc" | "desc"; + setJellyseerrSortOrder: (value: "asc" | "desc") => void; + t: (key: string) => string; +} + +const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) => + Number.isNaN(Number(v)), +); + +const orderOptions = ["asc", "desc"] as const; + +export const DiscoverFilters: React.FC = ({ + searchFilterId, + orderFilterId, + jellyseerrOrderBy, + setJellyseerrOrderBy, + jellyseerrSortOrder, + setJellyseerrSortOrder, + t, +}) => { + if (Platform.OS === "ios") { + return ( + + + + + + + + t(`home.settings.plugins.jellyseerr.order_by.${item}`), + )} + variant='menu' + selectedIndex={sortOptions.indexOf( + jellyseerrOrderBy as unknown as string, + )} + onOptionSelected={(event: any) => { + const index = event.nativeEvent.index; + setJellyseerrOrderBy( + sortOptions[index] as unknown as JellyseerrSearchSort, + ); + }} + /> + t(`library.filters.${item}`))} + variant='menu' + selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)} + onOptionSelected={(event: any) => { + const index = event.nativeEvent.index; + setJellyseerrSortOrder(orderOptions[index]); + }} + /> + + + + ); + } + + // Android UI + return ( + + + Object.keys(JellyseerrSearchSort).filter((v) => + Number.isNaN(Number(v)), + ) + } + set={(value) => setJellyseerrOrderBy(value[0])} + values={[jellyseerrOrderBy]} + title={t("library.filters.sort_by")} + renderItemLabel={(item) => + t(`home.settings.plugins.jellyseerr.order_by.${item}`) + } + disableSearch={true} + /> + ["asc", "desc"]} + set={(value) => setJellyseerrSortOrder(value[0])} + values={[jellyseerrSortOrder]} + title={t("library.filters.sort_order")} + renderItemLabel={(item) => t(`library.filters.${item}`)} + disableSearch={true} + /> + + ); +}; diff --git a/components/search/SearchTabButtons.tsx b/components/search/SearchTabButtons.tsx new file mode 100644 index 00000000..b312b82e --- /dev/null +++ b/components/search/SearchTabButtons.tsx @@ -0,0 +1,76 @@ +import { Button, Host } from "@expo/ui/swift-ui"; +import { Platform, TouchableOpacity, View } from "react-native"; +import { Tag } from "@/components/GenreTags"; + +type SearchType = "Library" | "Discover"; + +interface SearchTabButtonsProps { + searchType: SearchType; + setSearchType: (type: SearchType) => void; + t: (key: string) => string; +} + +export const SearchTabButtons: React.FC = ({ + searchType, + setSearchType, + t, +}) => { + if (Platform.OS === "ios") { + return ( + <> + + + + + + + + ); + } + + // Android UI + return ( + + setSearchType("Library")}> + + + setSearchType("Discover")}> + + + + ); +}; diff --git a/components/series/SeasonDropdown.tsx b/components/series/SeasonDropdown.tsx index e9b4f0e7..5f6b64cd 100644 --- a/components/series/SeasonDropdown.tsx +++ b/components/series/SeasonDropdown.tsx @@ -1,11 +1,9 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useEffect, useMemo } from "react"; -import { Platform, TouchableOpacity, View } from "react-native"; - -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - import { t } from "i18next"; +import { useEffect, useMemo, useState } from "react"; +import { Platform, TouchableOpacity, View } from "react-native"; import { Text } from "../common/Text"; +import { PlatformDropdown } from "../PlatformDropdown"; type Props = { item: BaseItemDto; @@ -33,6 +31,7 @@ export const SeasonDropdown: React.FC = ({ onSelect, }) => { const isTv = Platform.isTV; + const [open, setOpen] = useState(false); const keys = useMemo( () => @@ -55,6 +54,31 @@ export const SeasonDropdown: React.FC = ({ [state, item, keys], ); + const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => + Number(a[keys.index]) - Number(b[keys.index]); + + const optionGroups = useMemo( + () => [ + { + options: + seasons?.sort(sortByIndex).map((season: any) => { + const title = + season[keys.title] || + season.Name || + `Season ${season.IndexNumber}`; + return { + type: "radio" as const, + label: title, + value: season.Id || season.IndexNumber, + selected: Number(season[keys.index]) === Number(seasonIndex), + onPress: () => onSelect(season), + }; + }) || [], + }, + ], + [seasons, keys, seasonIndex, onSelect], + ); + useEffect(() => { if (isTv) return; if (seasons && seasons.length > 0 && seasonIndex === undefined) { @@ -96,45 +120,23 @@ export const SeasonDropdown: React.FC = ({ keys, ]); - const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => - Number(a[keys.index]) - Number(b[keys.index]); - if (isTv) return null; return ( - - - - + setOpen(true)}> + {t("item_card.season")} {seasonIndex} - - - - - {t("item_card.seasons")} - {seasons?.sort(sortByIndex).map((season: any) => { - const title = - season[keys.title] || season.Name || `Season ${season.IndexNumber}`; - return ( - onSelect(season)} - > - {title} - - ); - })} - - + + + } + title={t("item_card.seasons")} + /> ); }; diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx index e8242872..8e3051ee 100644 --- a/components/series/SeasonEpisodesCarousel.tsx +++ b/components/series/SeasonEpisodesCarousel.tsx @@ -29,7 +29,10 @@ export const SeasonEpisodesCarousel: React.FC = ({ const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { getDownloadedItems } = useDownload(); - const downloadedFiles = getDownloadedItems(); + const downloadedFiles = useMemo( + () => getDownloadedItems(), + [getDownloadedItems], + ); const scrollRef = useRef(null); diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index cfb62972..f97f6c0e 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -86,7 +86,7 @@ export const SeasonPicker: React.FC = ({ item }) => { seasonId: selectedSeasonId, enableUserData: true, // Note: Including trick play is necessary to enable trick play downloads - fields: ["Overview", "Trickplay"], + fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], }); if (res.data.TotalRecordCount === 0) diff --git a/components/settings/AppLanguageSelector.tsx b/components/settings/AppLanguageSelector.tsx index f77bc2c8..ac52896c 100644 --- a/components/settings/AppLanguageSelector.tsx +++ b/components/settings/AppLanguageSelector.tsx @@ -1,12 +1,12 @@ -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Platform, TouchableOpacity, View, type ViewProps } from "react-native"; +import { Platform, View, type ViewProps } from "react-native"; import { APP_LANGUAGES } from "@/i18n"; import { useSettings } from "@/utils/atoms/settings"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; +import { PlatformDropdown } from "../PlatformDropdown"; interface Props extends ViewProps {} @@ -15,6 +15,31 @@ export const AppLanguageSelector: React.FC = () => { const { settings, updateSettings } = useSettings(); const { t } = useTranslation(); + const optionGroups = useMemo(() => { + const options = [ + { + type: "radio" as const, + label: t("home.settings.languages.system"), + value: "system", + selected: !settings?.preferedLanguage, + onPress: () => updateSettings({ preferedLanguage: undefined }), + }, + ...APP_LANGUAGES.map((lang) => ({ + type: "radio" as const, + label: lang.label, + value: lang.value, + selected: lang.value === settings?.preferedLanguage, + onPress: () => updateSettings({ preferedLanguage: lang.value }), + })), + ]; + + return [ + { + options, + }, + ]; + }, [settings?.preferedLanguage, t, updateSettings]); + if (isTv) return null; if (!settings) return null; @@ -22,54 +47,19 @@ export const AppLanguageSelector: React.FC = () => { - - - + {APP_LANGUAGES.find( (l) => l.value === settings?.preferedLanguage, )?.label || t("home.settings.languages.system")} - - - - - {t("home.settings.languages.title")} - - { - updateSettings({ - preferedLanguage: undefined, - }); - }} - > - - {t("home.settings.languages.system")} - - - {APP_LANGUAGES?.map((l) => ( - { - updateSettings({ - preferedLanguage: l.value, - }); - }} - > - {l.label} - - ))} - - + + } + title={t("home.settings.languages.title")} + /> diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx index 82ff1157..1a1c1457 100644 --- a/components/settings/AudioToggles.tsx +++ b/components/settings/AudioToggles.tsx @@ -1,14 +1,13 @@ -import { Platform, TouchableOpacity, View, type ViewProps } from "react-native"; - -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - import { Ionicons } from "@expo/vector-icons"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { Platform, View, type ViewProps } from "react-native"; import { Switch } from "react-native-gesture-handler"; import { useSettings } from "@/utils/atoms/settings"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; +import { PlatformDropdown } from "../PlatformDropdown"; import { useMedia } from "./MediaContext"; interface Props extends ViewProps {} @@ -22,6 +21,39 @@ export const AudioToggles: React.FC = ({ ...props }) => { const cultures = media.cultures; const { t } = useTranslation(); + const optionGroups = useMemo(() => { + const options = [ + { + type: "radio" as const, + label: t("home.settings.audio.none"), + value: "none", + selected: !settings?.defaultAudioLanguage, + onPress: () => updateSettings({ defaultAudioLanguage: null }), + }, + ...(cultures?.map((culture) => ({ + type: "radio" as const, + label: + culture.DisplayName || + culture.ThreeLetterISOLanguageName || + "Unknown", + value: + culture.ThreeLetterISOLanguageName || + culture.DisplayName || + "unknown", + selected: + culture.ThreeLetterISOLanguageName === + settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName, + onPress: () => updateSettings({ defaultAudioLanguage: culture }), + })) || []), + ]; + + return [ + { + options, + }, + ]; + }, [cultures, settings?.defaultAudioLanguage, t, updateSettings]); + if (isTv) return null; if (!settings) return null; @@ -48,9 +80,10 @@ export const AudioToggles: React.FC = ({ ...props }) => { /> - - - + {settings?.defaultAudioLanguage?.DisplayName || t("home.settings.audio.none")} @@ -60,48 +93,10 @@ export const AudioToggles: React.FC = ({ ...props }) => { size={18} color='#5A5960' /> - - - - - {t("home.settings.audio.language")} - - { - updateSettings({ - defaultAudioLanguage: null, - }); - }} - > - - {t("home.settings.audio.none")} - - - {cultures?.map((l) => ( - { - updateSettings({ - defaultAudioLanguage: l, - }); - }} - > - - {l.DisplayName} - - - ))} - - + + } + title={t("home.settings.audio.language")} + /> diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx index 6c12440c..3a0017ac 100644 --- a/components/settings/DownloadSettings.tsx +++ b/components/settings/DownloadSettings.tsx @@ -1,44 +1,3 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Stepper } from "@/components/inputs/Stepper"; -import DisabledSetting from "@/components/settings/DisabledSetting"; -import { type Settings, useSettings } from "@/utils/atoms/settings"; -import { ListGroup } from "../list/ListGroup"; -import { ListItem } from "../list/ListItem"; - -export default function DownloadSettings({ ...props }) { - const { settings, updateSettings, pluginSettings } = useSettings(); - const { t } = useTranslation(); - - const allDisabled = useMemo( - () => - pluginSettings?.remuxConcurrentLimit?.locked === true && - pluginSettings?.autoDownload?.locked === true, - [pluginSettings], - ); - - if (!settings) return null; - - return ( - - - - - updateSettings({ - remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"], - }) - } - /> - - - - ); +export default function DownloadSettings() { + return null; } diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index e05e57b0..a93e219b 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -1,22 +1,15 @@ import { Ionicons } from "@expo/vector-icons"; import { useRouter } from "expo-router"; -import * as TaskManager from "expo-task-manager"; import { TFunction } from "i18next"; import type React from "react"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Linking, Platform, Switch, TouchableOpacity } from "react-native"; -import { toast } from "sonner-native"; +import { Linking, Switch, View } from "react-native"; import { BITRATES } from "@/components/BitrateSelector"; -import Dropdown from "@/components/common/Dropdown"; +import { PlatformDropdown } from "@/components/PlatformDropdown"; import DisabledSetting from "@/components/settings/DisabledSetting"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; -import { - BACKGROUND_FETCH_TASK, - registerBackgroundFetchAsync, - unregisterBackgroundFetchAsync, -} from "@/utils/background-tasks"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; @@ -27,39 +20,8 @@ export const OtherSettings: React.FC = () => { const { t } = useTranslation(); - /******************** - * Background task - *******************/ - const checkStatusAsync = async () => { - if (Platform.isTV) return false; - return TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK); - }; - - useEffect(() => { - (async () => { - const registered = await checkStatusAsync(); - - if (settings?.autoDownload === true && !registered) { - registerBackgroundFetchAsync(); - toast.success(t("home.settings.toasts.background_downloads_enabled")); - } else if (settings?.autoDownload === false && registered) { - unregisterBackgroundFetchAsync(); - toast.info(t("home.settings.toasts.background_downloads_disabled")); - } else if (settings?.autoDownload === true && registered) { - // Don't to anything - } else if (settings?.autoDownload === false && !registered) { - // Don't to anything - } else { - updateSettings({ autoDownload: false }); - } - })(); - }, [settings?.autoDownload]); - /********************** - *********************/ - const disabled = useMemo( () => - pluginSettings?.followDeviceOrientation?.locked === true && pluginSettings?.defaultVideoOrientation?.locked === true && pluginSettings?.safeAreaInControlsEnabled?.locked === true && pluginSettings?.showCustomMenuLinks?.locked === true && @@ -89,41 +51,65 @@ export const OtherSettings: React.FC = () => { [], ); + const orientationOptions = useMemo( + () => [ + { + options: orientations.map((orientation) => ({ + type: "radio" as const, + label: t(ScreenOrientationEnum[orientation]), + value: String(orientation), + selected: orientation === settings?.defaultVideoOrientation, + onPress: () => + updateSettings({ defaultVideoOrientation: orientation }), + })), + }, + ], + [orientations, settings?.defaultVideoOrientation, t, updateSettings], + ); + + const bitrateOptions = useMemo( + () => [ + { + options: BITRATES.map((bitrate) => ({ + type: "radio" as const, + label: bitrate.key, + value: bitrate.key, + selected: bitrate.key === settings?.defaultBitrate?.key, + onPress: () => updateSettings({ defaultBitrate: bitrate }), + })), + }, + ], + [settings?.defaultBitrate?.key, t, updateSettings], + ); + + const autoPlayEpisodeOptions = useMemo( + () => [ + { + options: AUTOPLAY_EPISODES_COUNT(t).map((item) => ({ + type: "radio" as const, + label: item.key, + value: item.key, + selected: item.key === settings?.maxAutoPlayEpisodeCount?.key, + onPress: () => updateSettings({ maxAutoPlayEpisodeCount: item }), + })), + }, + ], + [settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings], + ); + if (!settings) return null; return ( - - - updateSettings({ followDeviceOrientation: value }) - } - /> - - - t(ScreenOrientationEnum[item])} - title={ - + {t( orientationTranslations[ @@ -136,12 +122,9 @@ export const OtherSettings: React.FC = () => { size={18} color='#5A5960' /> - - } - label={t("home.settings.other.orientation")} - onSelected={(defaultVideoOrientation) => - updateSettings({ defaultVideoOrientation }) + } + title={t("home.settings.other.orientation")} /> @@ -222,13 +205,10 @@ export const OtherSettings: React.FC = () => { title={t("home.settings.other.default_quality")} disabled={pluginSettings?.defaultBitrate?.locked} > - item.key} - titleExtractor={(item) => item.key} - title={ - + {settings.defaultBitrate?.key} @@ -237,10 +217,9 @@ export const OtherSettings: React.FC = () => { size={18} color='#5A5960' /> - + } - label={t("home.settings.other.default_quality")} - onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })} + title={t("home.settings.other.default_quality")} /> { /> - item.key} - titleExtractor={(item) => item.key} - title={ - + {t(settings?.maxAutoPlayEpisodeCount.key)} @@ -270,12 +247,9 @@ export const OtherSettings: React.FC = () => { size={18} color='#5A5960' /> - - } - label={t("home.settings.other.max_auto_play_episode_count")} - onSelected={(maxAutoPlayEpisodeCount) => - updateSettings({ maxAutoPlayEpisodeCount }) + } + title={t("home.settings.other.max_auto_play_episode_count")} /> diff --git a/components/settings/PluginSettings.tsx b/components/settings/PluginSettings.tsx index 9d8c64dd..1fb064ef 100644 --- a/components/settings/PluginSettings.tsx +++ b/components/settings/PluginSettings.tsx @@ -14,7 +14,7 @@ export const PluginSettings = () => { if (!settings) return null; return ( - + = ({ ...props }) => { const isTv = Platform.isTV; @@ -27,18 +29,6 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { const cultures = media.cultures; const { t } = useTranslation(); - // Get VLC subtitle settings from the settings system - const textColor = settings?.vlcTextColor ?? "White"; - const backgroundColor = settings?.vlcBackgroundColor ?? "Black"; - const outlineColor = settings?.vlcOutlineColor ?? "Black"; - const outlineThickness = settings?.vlcOutlineThickness ?? "Normal"; - const backgroundOpacity = settings?.vlcBackgroundOpacity ?? 128; - const outlineOpacity = settings?.vlcOutlineOpacity ?? 255; - const isBold = settings?.vlcIsBold ?? false; - - if (isTv) return null; - if (!settings) return null; - const subtitleModes = [ SubtitlePlaybackMode.Default, SubtitlePlaybackMode.Smart, @@ -56,6 +46,133 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { [SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None", }; + const subtitleLanguageOptionGroups = useMemo(() => { + const options = [ + { + type: "radio" as const, + label: t("home.settings.subtitles.none"), + value: "none", + selected: !settings?.defaultSubtitleLanguage, + onPress: () => updateSettings({ defaultSubtitleLanguage: null }), + }, + ...(cultures?.map((culture) => ({ + type: "radio" as const, + label: culture.DisplayName || "Unknown", + value: + culture.ThreeLetterISOLanguageName || + culture.DisplayName || + "unknown", + selected: + culture.ThreeLetterISOLanguageName === + settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName, + onPress: () => updateSettings({ defaultSubtitleLanguage: culture }), + })) || []), + ]; + + return [ + { + options, + }, + ]; + }, [cultures, settings?.defaultSubtitleLanguage, t, updateSettings]); + + const subtitleModeOptionGroups = useMemo(() => { + const options = subtitleModes.map((mode) => ({ + type: "radio" as const, + label: t(subtitleModeKeys[mode]) || String(mode), + value: String(mode), + selected: mode === settings?.subtitleMode, + onPress: () => updateSettings({ subtitleMode: mode }), + })); + + return [ + { + options, + }, + ]; + }, [settings?.subtitleMode, t, updateSettings]); + + const textColorOptionGroups = useMemo(() => { + const colors = Object.keys(VLC_COLORS) as VLCColor[]; + const options = colors.map((color) => ({ + type: "radio" as const, + label: t(`home.settings.subtitles.colors.${color}`), + value: color, + selected: (settings?.vlcTextColor || "White") === color, + onPress: () => updateSettings({ vlcTextColor: color }), + })); + + return [{ options }]; + }, [settings?.vlcTextColor, t, updateSettings]); + + const backgroundColorOptionGroups = useMemo(() => { + const colors = Object.keys(VLC_COLORS) as VLCColor[]; + const options = colors.map((color) => ({ + type: "radio" as const, + label: t(`home.settings.subtitles.colors.${color}`), + value: color, + selected: (settings?.vlcBackgroundColor || "Black") === color, + onPress: () => updateSettings({ vlcBackgroundColor: color }), + })); + + return [{ options }]; + }, [settings?.vlcBackgroundColor, t, updateSettings]); + + const outlineColorOptionGroups = useMemo(() => { + const colors = Object.keys(VLC_COLORS) as VLCColor[]; + const options = colors.map((color) => ({ + type: "radio" as const, + label: t(`home.settings.subtitles.colors.${color}`), + value: color, + selected: (settings?.vlcOutlineColor || "Black") === color, + onPress: () => updateSettings({ vlcOutlineColor: color }), + })); + + return [{ options }]; + }, [settings?.vlcOutlineColor, t, updateSettings]); + + const outlineThicknessOptionGroups = useMemo(() => { + const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[]; + const options = thicknesses.map((thickness) => ({ + type: "radio" as const, + label: t(`home.settings.subtitles.thickness.${thickness}`), + value: thickness, + selected: (settings?.vlcOutlineThickness || "Normal") === thickness, + onPress: () => updateSettings({ vlcOutlineThickness: thickness }), + })); + + return [{ options }]; + }, [settings?.vlcOutlineThickness, t, updateSettings]); + + const backgroundOpacityOptionGroups = useMemo(() => { + const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255]; + const options = opacities.map((opacity) => ({ + type: "radio" as const, + label: `${Math.round((opacity / 255) * 100)}%`, + value: opacity, + selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity, + onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }), + })); + + return [{ options }]; + }, [settings?.vlcBackgroundOpacity, updateSettings]); + + const outlineOpacityOptionGroups = useMemo(() => { + const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255]; + const options = opacities.map((opacity) => ({ + type: "radio" as const, + label: `${Math.round((opacity / 255) * 100)}%`, + value: opacity, + selected: (settings?.vlcOutlineOpacity ?? 255) === opacity, + onPress: () => updateSettings({ vlcOutlineOpacity: opacity }), + })); + + return [{ options }]; + }, [settings?.vlcOutlineOpacity, updateSettings]); + + if (isTv) return null; + if (!settings) return null; + return ( = ({ ...props }) => { } > - - item?.ThreeLetterISOLanguageName ?? "unknown" - } - titleExtractor={(item) => item?.DisplayName} - title={ - + {settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")} @@ -90,18 +197,9 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { size={18} color='#5A5960' /> - - } - label={t("home.settings.subtitles.language")} - onSelected={(defaultSubtitleLanguage) => - updateSettings({ - defaultSubtitleLanguage: - defaultSubtitleLanguage.DisplayName === - t("home.settings.subtitles.none") - ? null - : defaultSubtitleLanguage, - }) + } + title={t("home.settings.subtitles.language")} /> @@ -109,13 +207,10 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { title={t("home.settings.subtitles.subtitle_mode")} disabled={pluginSettings?.subtitleMode?.locked} > - t(subtitleModeKeys[item]) || String(item)} - title={ - + {t(subtitleModeKeys[settings?.subtitleMode]) || t("home.settings.subtitles.loading")} @@ -125,10 +220,9 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { size={18} color='#5A5960' /> - + } - label={t("home.settings.subtitles.subtitle_mode")} - onSelected={(subtitleMode) => updateSettings({ subtitleMode })} + title={t("home.settings.subtitles.subtitle_mode")} /> @@ -159,144 +253,120 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { /> - item} - titleExtractor={(item) => - t(`home.settings.subtitles.colors.${item}`) - } - title={ - + - {t(`home.settings.subtitles.colors.${textColor}`)} + {t( + `home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`, + )} - + } - label={t("home.settings.subtitles.text_color")} - onSelected={(value) => updateSettings({ vlcTextColor: value })} + title={t("home.settings.subtitles.text_color")} /> - item} - titleExtractor={(item) => - t(`home.settings.subtitles.colors.${item}`) - } - title={ - + - {t(`home.settings.subtitles.colors.${backgroundColor}`)} + {t( + `home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`, + )} - - } - label={t("home.settings.subtitles.background_color")} - onSelected={(value) => - updateSettings({ vlcBackgroundColor: value }) + } + title={t("home.settings.subtitles.background_color")} /> - item} - titleExtractor={(item) => - t(`home.settings.subtitles.colors.${item}`) - } - title={ - + - {t(`home.settings.subtitles.colors.${outlineColor}`)} + {t( + `home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`, + )} - + } - label={t("home.settings.subtitles.outline_color")} - onSelected={(value) => updateSettings({ vlcOutlineColor: value })} + title={t("home.settings.subtitles.outline_color")} /> - item} - titleExtractor={(item) => - t(`home.settings.subtitles.thickness.${item}`) - } - title={ - + - {t(`home.settings.subtitles.thickness.${outlineThickness}`)} + {t( + `home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`, + )} - - } - label={t("home.settings.subtitles.outline_thickness")} - onSelected={(value) => - updateSettings({ vlcOutlineThickness: value }) + } + title={t("home.settings.subtitles.outline_thickness")} /> - `${Math.round((item / 255) * 100)}%`} - title={ - - {`${Math.round((backgroundOpacity / 255) * 100)}%`} + + {`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`} - - } - label={t("home.settings.subtitles.background_opacity")} - onSelected={(value) => - updateSettings({ vlcBackgroundOpacity: value }) + } + title={t("home.settings.subtitles.background_opacity")} /> - `${Math.round((item / 255) * 100)}%`} - title={ - - {`${Math.round((outlineOpacity / 255) * 100)}%`} + + {`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`} - + } - label={t("home.settings.subtitles.outline_opacity")} - onSelected={(value) => updateSettings({ vlcOutlineOpacity: value })} + title={t("home.settings.subtitles.outline_opacity")} /> updateSettings({ vlcIsBold: value })} /> diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 59da6f7b..a2652d70 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -114,10 +114,7 @@ export const BottomControls: FC = ({ }} > {item?.Type === "Episode" && ( @@ -133,7 +130,7 @@ export const BottomControls: FC = ({ {item?.Album} )} - + = ({ }>(); const { showSkipButton, skipIntro } = useIntroSkipper( - item?.Id!, + item.Id!, currentTime, seek, play, @@ -332,7 +332,7 @@ export const Controls: FC = ({ ); const { showSkipCreditButton, skipCredit } = useCreditSkipper( - item?.Id!, + item.Id!, currentTime, seek, play, diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx index f39b0d7e..0a90b429 100644 --- a/components/video-player/controls/EpisodeList.tsx +++ b/components/video-player/controls/EpisodeList.tsx @@ -56,7 +56,10 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { }, []); const { getDownloadedItems } = useDownload(); - const downloadedFiles = getDownloadedItems(); + const downloadedFiles = useMemo( + () => getDownloadedItems(), + [getDownloadedItems], + ); const seasonIndex = seasonIndexState[item.ParentId ?? ""]; @@ -68,13 +71,13 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { const seriesEpisodes = downloadedFiles?.filter( (f: DownloadedItem) => f.item.SeriesId === item.SeriesId, ); - const seasonNumbers = [ - ...new Set( + const seasonNumbers = Array.from( + new Set( seriesEpisodes ?.map((f: DownloadedItem) => f.item.ParentIndexNumber) .filter(Boolean), ), - ]; + ); // Create fake season objects return seasonNumbers.map((seasonNumber) => ({ Id: seasonNumber?.toString(), diff --git a/components/video-player/controls/HeaderControls.tsx b/components/video-player/controls/HeaderControls.tsx index 07232014..d8e4dcfe 100644 --- a/components/video-player/controls/HeaderControls.tsx +++ b/components/video-player/controls/HeaderControls.tsx @@ -111,7 +111,7 @@ export const HeaderControls: FC = ({ pointerEvents={showControls ? "auto" : "none"} className={"flex flex-row w-full pt-2"} > - + {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( = ({ setSubtitleTrack={setSubtitleTrack} setSubtitleURL={setSubtitleURL} > - + + + )} diff --git a/components/video-player/controls/ScaleFactorSelector.tsx b/components/video-player/controls/ScaleFactorSelector.tsx index b6ed1853..0e5f2b10 100644 --- a/components/video-player/controls/ScaleFactorSelector.tsx +++ b/components/video-player/controls/ScaleFactorSelector.tsx @@ -1,8 +1,10 @@ import { Ionicons } from "@expo/vector-icons"; -import React, { useState } from "react"; -import { Platform, TouchableOpacity } from "react-native"; -import { Text } from "@/components/common/Text"; -import { FilterSheet } from "@/components/filters/FilterSheet"; +import React, { useMemo } from "react"; +import { Platform, View } from "react-native"; +import { + type OptionGroup, + PlatformDropdown, +} from "@/components/PlatformDropdown"; import { useHaptic } from "@/hooks/useHaptic"; export type ScaleFactor = @@ -94,56 +96,51 @@ export const ScaleFactorSelector: React.FC = ({ disabled = false, }) => { const lightHapticFeedback = useHaptic("light"); - const [open, setOpen] = useState(false); - - // Hide on TV platforms - if (Platform.isTV) return null; const handleScaleSelect = (scale: ScaleFactor) => { onScaleChange(scale); lightHapticFeedback(); }; - const currentOption = SCALE_FACTOR_OPTIONS.find( - (option) => option.id === currentScale, - ); + const optionGroups = useMemo(() => { + return [ + { + options: SCALE_FACTOR_OPTIONS.map((option) => ({ + type: "radio" as const, + label: option.label, + value: option.id, + selected: option.id === currentScale, + onPress: () => handleScaleSelect(option.id), + disabled, + })), + }, + ]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentScale, disabled]); - return ( - <> - ( + setOpen(true)} > - + + ), + [disabled], + ); - { - const option = item as ScaleFactorOption; - return ( - option.label.toLowerCase().includes(query.toLowerCase()) || - option.description.toLowerCase().includes(query.toLowerCase()) - ); - }} - renderItemLabel={(item) => { - const option = item as ScaleFactorOption; - return {option.label}; - }} - set={(vals) => { - const chosen = vals[0] as ScaleFactorOption | undefined; - if (chosen) { - handleScaleSelect(chosen.id); - } - }} - /> - + // Hide on TV platforms + if (Platform.isTV) return null; + + return ( + ); }; diff --git a/components/video-player/controls/SkipButton.tsx b/components/video-player/controls/SkipButton.tsx index 016f94d1..2e34e7b7 100644 --- a/components/video-player/controls/SkipButton.tsx +++ b/components/video-player/controls/SkipButton.tsx @@ -13,6 +13,12 @@ const SkipButton: React.FC = ({ buttonText, ...props }) => { + console.log(`[SKIP_BUTTON] Render:`, { + buttonText, + showButton, + className: showButton ? "flex" : "hidden", + }); + return ( = ({ disabled = false, }) => { const lightHapticFeedback = useHaptic("light"); - const [open, setOpen] = useState(false); - - // Hide on TV platforms - if (Platform.isTV) return null; const handleRatioSelect = (ratio: AspectRatio) => { onRatioChange(ratio); lightHapticFeedback(); }; - const currentOption = ASPECT_RATIO_OPTIONS.find( - (option) => option.id === currentRatio, - ); + const optionGroups = useMemo(() => { + return [ + { + options: ASPECT_RATIO_OPTIONS.map((option) => ({ + type: "radio" as const, + label: option.label, + value: option.id, + selected: option.id === currentRatio, + onPress: () => handleRatioSelect(option.id), + disabled, + })), + }, + ]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentRatio, disabled]); - return ( - <> - ( + setOpen(true)} > - + + ), + [disabled], + ); - { - const option = item as AspectRatioOption; - return ( - option.label.toLowerCase().includes(query.toLowerCase()) || - option.description.toLowerCase().includes(query.toLowerCase()) - ); - }} - renderItemLabel={(item) => { - const option = item as AspectRatioOption; - return {option.label}; - }} - set={(vals) => { - const chosen = vals[0] as AspectRatioOption | undefined; - if (chosen) { - handleRatioSelect(chosen.id); - } - }} - /> - + // Hide on TV platforms + if (Platform.isTV) return null; + + return ( + ); }; diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index c6c8f82f..e1332e43 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -1,16 +1,12 @@ import { Ionicons } from "@expo/vector-icons"; -import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetScrollView, -} from "@gorhom/bottom-sheet"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useCallback, useMemo, useRef } from "react"; +import { Platform, View } from "react-native"; import { BITRATES } from "@/components/BitrateSelector"; -import { Text } from "@/components/common/Text"; +import { + type OptionGroup, + PlatformDropdown, +} from "@/components/PlatformDropdown"; import { useControlContext } from "../contexts/ControlContext"; import { useVideoContext } from "../contexts/VideoContext"; @@ -23,10 +19,6 @@ const DropdownView = () => { ControlContext?.mediaSource, ]; const router = useRouter(); - const insets = useSafeAreaInsets(); - const [open, setOpen] = useState(false); - const bottomSheetModalRef = useRef(null); - const snapPoints = useMemo(() => ["75%"], []); const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } = useLocalSearchParams<{ @@ -39,248 +31,127 @@ const DropdownView = () => { offline: string; }>(); + // Use ref to track playbackPosition without causing re-renders + const playbackPositionRef = useRef(playbackPosition); + playbackPositionRef.current = playbackPosition; + const isOffline = offline === "true"; + // Stabilize IDs to prevent unnecessary recalculations + const itemIdRef = useRef(item.Id); + const mediaSourceIdRef = useRef(mediaSource?.Id); + itemIdRef.current = item.Id; + mediaSourceIdRef.current = mediaSource?.Id; + const changeBitrate = useCallback( (bitrate: string) => { const queryParams = new URLSearchParams({ - itemId: item.Id ?? "", + itemId: itemIdRef.current ?? "", audioIndex: audioIndex?.toString() ?? "", - subtitleIndex: subtitleIndex.toString() ?? "", - mediaSourceId: mediaSource?.Id ?? "", + subtitleIndex: subtitleIndex?.toString() ?? "", + mediaSourceId: mediaSourceIdRef.current ?? "", bitrateValue: bitrate.toString(), - playbackPosition: playbackPosition, + playbackPosition: playbackPositionRef.current, }).toString(); router.replace(`player/direct-player?${queryParams}` as any); }, - [item, mediaSource, subtitleIndex, audioIndex, playbackPosition], + [audioIndex, subtitleIndex, router], ); - const handleSheetChanges = useCallback((index: number) => { - if (index === -1) { - setOpen(false); - } - }, []); + // Create stable identifiers for tracks + const subtitleTracksKey = useMemo( + () => subtitleTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "", + [subtitleTracks], + ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - + const audioTracksKey = useMemo( + () => audioTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "", + [audioTracks], + ); + + // Transform sections into OptionGroup format + const optionGroups = useMemo(() => { + const groups: OptionGroup[] = []; + + // Quality Section + if (!isOffline) { + groups.push({ + title: "Quality", + options: + BITRATES?.map((bitrate) => ({ + type: "radio" as const, + label: bitrate.key, + value: bitrate.value?.toString() ?? "", + selected: bitrateValue === (bitrate.value?.toString() ?? ""), + onPress: () => changeBitrate(bitrate.value?.toString() ?? ""), + })) || [], + }); + } + + // Subtitle Section + if (subtitleTracks && subtitleTracks.length > 0) { + groups.push({ + title: "Subtitles", + options: subtitleTracks.map((sub) => ({ + type: "radio" as const, + label: sub.name, + value: sub.index.toString(), + selected: subtitleIndex === sub.index.toString(), + onPress: () => sub.setTrack(), + })), + }); + } + + // Audio Section + if (audioTracks && audioTracks.length > 0) { + groups.push({ + title: "Audio", + options: audioTracks.map((track) => ({ + type: "radio" as const, + label: track.name, + value: track.index.toString(), + selected: audioIndex === track.index.toString(), + onPress: () => track.setTrack(), + })), + }); + } + + return groups; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + isOffline, + bitrateValue, + changeBitrate, + subtitleTracksKey, + audioTracksKey, + subtitleIndex, + audioIndex, + // Note: subtitleTracks and audioTracks are intentionally excluded + // because we use subtitleTracksKey and audioTracksKey for stability + ]); + + // Memoize the trigger to prevent re-renders + const trigger = useMemo( + () => ( + + + ), [], ); - const handleOpen = () => { - setOpen(true); - bottomSheetModalRef.current?.present(); - }; - - const handleClose = () => { - setOpen(false); - bottomSheetModalRef.current?.dismiss(); - }; - - useEffect(() => { - if (open) bottomSheetModalRef.current?.present(); - else bottomSheetModalRef.current?.dismiss(); - }, [open]); - // Hide on TV platforms if (Platform.isTV) return null; return ( - <> - - - - - - - - Playback Options - - {/* Quality Section */} - {!isOffline && ( - - - Quality - - - {BITRATES?.map((bitrate, idx: number) => ( - - { - changeBitrate(bitrate.value?.toString() ?? ""); - setTimeout(() => handleClose(), 250); - }} - className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between' - > - {bitrate.key} - {bitrateValue === (bitrate.value?.toString() ?? "") ? ( - - ) : ( - - )} - - {idx < BITRATES.length - 1 && ( - - )} - - ))} - - - )} - - {/* Subtitle Section */} - - - Subtitles - - - {subtitleTracks?.map((sub, idx: number) => ( - - { - sub.setTrack(); - setTimeout(() => handleClose(), 250); - }} - className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between' - > - {sub.name} - {subtitleIndex === sub.index.toString() ? ( - - ) : ( - - )} - - {idx < (subtitleTracks?.length ?? 0) - 1 && ( - - )} - - ))} - - - - {/* Audio Section */} - {(audioTracks?.length ?? 0) > 0 && ( - - - Audio - - - {audioTracks?.map((track, idx: number) => ( - - { - track.setTrack(); - setTimeout(() => handleClose(), 250); - }} - className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between' - > - {track.name} - {audioIndex === track.index.toString() ? ( - - ) : ( - - )} - - {idx < (audioTracks?.length ?? 0) - 1 && ( - - )} - - ))} - - - )} - - - - + ); }; diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts index b1df69fd..8eba2c45 100644 --- a/components/video-player/controls/hooks/useRemoteControl.ts +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -1,9 +1,23 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useTVEventHandler } from "react-native"; +import { Platform } from "react-native"; import { type SharedValue, useSharedValue } from "react-native-reanimated"; import { msToTicks, ticksToSeconds } from "@/utils/time"; import { CONTROLS_CONSTANTS } from "../constants"; +// TV event handler with fallback for non-TV platforms +let useTVEventHandler: (callback: (evt: any) => void) => void; +if (Platform.isTV) { + try { + useTVEventHandler = require("react-native").useTVEventHandler; + } catch { + // Fallback for non-TV platforms + useTVEventHandler = () => {}; + } +} else { + // No-op hook for non-TV platforms + useTVEventHandler = () => {}; +} + interface UseRemoteControlProps { progress: SharedValue; min: SharedValue; @@ -63,6 +77,7 @@ export function useRemoteControl({ [isVlc], ); + // TV remote control handling (no-op on non-TV platforms) useTVEventHandler((evt) => { if (!evt) return; diff --git a/components/video-player/controls/useControlsTimeout.ts b/components/video-player/controls/useControlsTimeout.ts index d1e95b8c..80d41af2 100644 --- a/components/video-player/controls/useControlsTimeout.ts +++ b/components/video-player/controls/useControlsTimeout.ts @@ -13,7 +13,7 @@ export const useControlsTimeout = ({ isSliding, episodeView, onHideControls, - timeout = 4000, + timeout = 10000, }: UseControlsTimeoutProps) => { const controlsTimeoutRef = useRef | null>(null); diff --git a/docs/nested-modals.md b/docs/nested-modals.md new file mode 100644 index 00000000..c0010fda --- /dev/null +++ b/docs/nested-modals.md @@ -0,0 +1,58 @@ +# Nested Modals with PlatformDropdown + +## Issue +PlatformDropdowns inside BottomSheetModals don't open on Android. + +## Solution +1. **Add controlled state** for each PlatformDropdown: + ```tsx + const [open, setOpen] = useState(false); + + + ``` + +2. **Use `View` for triggers, not `TouchableOpacity`**: + ```tsx + // ✅ Correct + ...} + /> + + // ❌ Wrong - causes nested TouchableOpacity conflicts + ...} + /> + ``` + +3. **Add `stackBehavior='push'` to parent BottomSheetModal**: + ```tsx + + ``` + +4. **Reset dropdown states on modal dismiss**: + ```tsx + const handleDismiss = useCallback(() => { + setDropdown1Open(false); + setDropdown2Open(false); + // reset all dropdown states + onDismiss?.(); + }, [onDismiss]); + + + ``` + +## Why +- PlatformDropdown wraps triggers in TouchableOpacity on Android. Nested TouchableOpacity causes touch event conflicts. +- PlatformDropdown's useEffect should only call `showModal()` when `open === true`, not call `hideModal()` when `open === false` (interferes with parent modals). +- Dropdown states must be reset on modal dismiss to prevent them from reopening automatically when parent modal reopens. + diff --git a/eas.json b/eas.json index e23dfbf5..9463c30f 100644 --- a/eas.json +++ b/eas.json @@ -45,14 +45,14 @@ }, "production": { "environment": "production", - "channel": "0.40.4", + "channel": "0.46.2", "android": { "image": "latest" } }, "production-apk": { "environment": "production", - "channel": "0.40.4", + "channel": "0.46.2", "android": { "buildType": "apk", "image": "latest" @@ -60,7 +60,7 @@ }, "production-apk-tv": { "environment": "production", - "channel": "0.40.4", + "channel": "0.46.2", "android": { "buildType": "apk", "image": "latest" diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts index 14004596..b25551d3 100644 --- a/hooks/useIntroSkipper.ts +++ b/hooks/useIntroSkipper.ts @@ -43,26 +43,60 @@ export const useIntroSkipper = ( const introTimestamps = segments?.introSegments?.[0]; useEffect(() => { + console.log(`[INTRO_SKIPPER] Hook state:`, { + itemId, + currentTime, + hasSegments: !!segments, + segments: segments, + introSegmentsCount: segments?.introSegments?.length || 0, + introSegments: segments?.introSegments, + hasIntroTimestamps: !!introTimestamps, + introTimestamps, + isVlc, + isOffline, + }); + if (introTimestamps) { - setShowSkipButton( + const shouldShow = currentTime > introTimestamps.startTime && - currentTime < introTimestamps.endTime, - ); + currentTime < introTimestamps.endTime; + + console.log(`[INTRO_SKIPPER] Button visibility check:`, { + currentTime, + introStart: introTimestamps.startTime, + introEnd: introTimestamps.endTime, + afterStart: currentTime > introTimestamps.startTime, + beforeEnd: currentTime < introTimestamps.endTime, + shouldShow, + }); + + setShowSkipButton(shouldShow); + } else { + if (showSkipButton) { + console.log(`[INTRO_SKIPPER] No intro timestamps, hiding button`); + setShowSkipButton(false); + } } - }, [introTimestamps, currentTime]); + }, [introTimestamps, currentTime, showSkipButton]); const skipIntro = useCallback(() => { if (!introTimestamps) return; try { + console.log( + `[INTRO_SKIPPER] Skipping intro to:`, + introTimestamps.endTime, + ); lightHapticFeedback(); wrappedSeek(introTimestamps.endTime); setTimeout(() => { play(); }, 200); } catch (error) { - console.error("Error skipping intro", error); + console.error("[INTRO_SKIPPER] Error skipping intro", error); } }, [introTimestamps, lightHapticFeedback, wrappedSeek, play]); + console.log(`[INTRO_SKIPPER] Returning state:`, { showSkipButton }); + return { showSkipButton, skipIntro }; }; diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index f855b332..34cc4dfe 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -66,8 +66,8 @@ const JELLYSEERR_USER = "JELLYSEERR_USER"; const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES"; export const clearJellyseerrStorageData = () => { - storage.delete(JELLYSEERR_USER); - storage.delete(JELLYSEERR_COOKIES); + storage.remove(JELLYSEERR_USER); + storage.remove(JELLYSEERR_COOKIES); }; export enum Endpoints { diff --git a/hooks/useOrientation.ts b/hooks/useOrientation.ts index 80a01ffb..62270c4f 100644 --- a/hooks/useOrientation.ts +++ b/hooks/useOrientation.ts @@ -1,7 +1,23 @@ import { useEffect, useState } from "react"; import { Platform } from "react-native"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; -import orientationToOrientationLock from "@/utils/OrientationLockConverter"; +import { OrientationLock } from "@/packages/expo-screen-orientation"; +import { Orientation } from "../packages/expo-screen-orientation.tv"; + +const orientationToOrientationLock = ( + orientation: Orientation, +): OrientationLock => { + switch (orientation) { + case Orientation.LANDSCAPE_LEFT: + return OrientationLock.LANDSCAPE_LEFT; + case Orientation.LANDSCAPE_RIGHT: + return OrientationLock.LANDSCAPE_RIGHT; + case Orientation.PORTRAIT_UP: + return OrientationLock.PORTRAIT_UP; + default: + return OrientationLock.PORTRAIT_UP; + } +}; export const useOrientation = () => { const [orientation, setOrientation] = useState( @@ -29,5 +45,20 @@ export const useOrientation = () => { }; }, []); - return { orientation, setOrientation }; + const lockOrientation = async (lock: OrientationLock) => { + if (Platform.isTV) return; + + if (lock === ScreenOrientation.OrientationLock.DEFAULT) { + await ScreenOrientation.unlockAsync(); + } else { + await ScreenOrientation.lockAsync(lock); + } + }; + + const unlockOrientation = async () => { + if (Platform.isTV) return; + await ScreenOrientation.unlockAsync(); + }; + + return { orientation, setOrientation, lockOrientation, unlockOrientation }; }; diff --git a/metro.config.js b/metro.config.js index 2e9a0e06..ff7d87fb 100644 --- a/metro.config.js +++ b/metro.config.js @@ -2,7 +2,7 @@ const { getDefaultConfig } = require("expo/metro-config"); /** @type {import('expo/metro-config').MetroConfig} */ -const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef +const config = getDefaultConfig(__dirname); // Add Hermes parser config.transformer.hermesParser = true; diff --git a/modules/background-downloader/README.md b/modules/background-downloader/README.md new file mode 100644 index 00000000..53e419b5 --- /dev/null +++ b/modules/background-downloader/README.md @@ -0,0 +1,258 @@ +# Background Downloader Module + +A native iOS and Android module for downloading large files in the background using `NSURLSession` (iOS) and `DownloadManager` (Android). + +## Features + +- **Background Downloads**: Downloads continue even when the app is backgrounded or suspended +- **Progress Tracking**: Real-time progress updates via events +- **Multiple Downloads**: Support for concurrent downloads +- **Cancellation**: Cancel individual or all downloads +- **Custom Destination**: Optionally specify custom file paths +- **Error Handling**: Comprehensive error reporting +- **Cross-Platform**: Works on both iOS and Android + +## Usage + +### Basic Example + +```typescript +import { BackgroundDownloader } from '@/modules'; + +// Start a download +const taskId = await BackgroundDownloader.startDownload( + 'https://example.com/largefile.mp4' +); + +// Listen for progress updates +const progressSub = BackgroundDownloader.addProgressListener((event) => { + console.log(`Progress: ${Math.floor(event.progress * 100)}%`); + console.log(`Downloaded: ${event.bytesWritten} / ${event.totalBytes}`); +}); + +// Listen for completion +const completeSub = BackgroundDownloader.addCompleteListener((event) => { + console.log('Download complete!'); + console.log('File saved to:', event.filePath); + console.log('Task ID:', event.taskId); +}); + +// Listen for errors +const errorSub = BackgroundDownloader.addErrorListener((event) => { + console.error('Download failed:', event.error); +}); + +// Cancel a download +BackgroundDownloader.cancelDownload(taskId); + +// Get all active downloads +const activeDownloads = await BackgroundDownloader.getActiveDownloads(); + +// Cleanup listeners when done +progressSub.remove(); +completeSub.remove(); +errorSub.remove(); +``` + +### Custom Destination Path + +```typescript +import { BackgroundDownloader } from '@/modules'; +import * as FileSystem from 'expo-file-system'; + +const destinationPath = `${FileSystem.documentDirectory}myfile.mp4`; +const taskId = await BackgroundDownloader.startDownload( + 'https://example.com/video.mp4', + destinationPath +); +``` + +### Managing Multiple Downloads + +```typescript +import { BackgroundDownloader } from '@/modules'; + +const downloads = new Map(); + +async function startMultipleDownloads(urls: string[]) { + for (const url of urls) { + const taskId = await BackgroundDownloader.startDownload(url); + downloads.set(taskId, { url, progress: 0 }); + } +} + +// Track progress for each download +const progressSub = BackgroundDownloader.addProgressListener((event) => { + const download = downloads.get(event.taskId); + if (download) { + download.progress = event.progress; + } +}); + +// Cancel all downloads +BackgroundDownloader.cancelAllDownloads(); +``` + +## API Reference + +### Methods + +#### `startDownload(url: string, destinationPath?: string): Promise` + +Starts a new background download. + +- **Parameters:** + - `url`: The URL of the file to download + - `destinationPath`: (Optional) Custom file path for the downloaded file +- **Returns:** Promise that resolves to the task ID (number) + +#### `cancelDownload(taskId: number): void` + +Cancels a specific download by task ID. + +- **Parameters:** + - `taskId`: The task ID returned by `startDownload` + +#### `cancelAllDownloads(): void` + +Cancels all active downloads. + +#### `getActiveDownloads(): Promise` + +Gets information about all active downloads. + +- **Returns:** Promise that resolves to an array of active downloads + +### Event Listeners + +#### `addProgressListener(listener: (event: DownloadProgressEvent) => void): Subscription` + +Listens for download progress updates. + +- **Event payload:** + - `taskId`: number + - `bytesWritten`: number + - `totalBytes`: number + - `progress`: number (0.0 to 1.0) + +#### `addCompleteListener(listener: (event: DownloadCompleteEvent) => void): Subscription` + +Listens for download completion. + +- **Event payload:** + - `taskId`: number + - `filePath`: string + - `url`: string + +#### `addErrorListener(listener: (event: DownloadErrorEvent) => void): Subscription` + +Listens for download errors. + +- **Event payload:** + - `taskId`: number + - `error`: string + +#### `addStartedListener(listener: (event: DownloadStartedEvent) => void): Subscription` + +Listens for download start confirmation. + +- **Event payload:** + - `taskId`: number + - `url`: string + +## Types + +```typescript +interface DownloadProgressEvent { + taskId: number; + bytesWritten: number; + totalBytes: number; + progress: number; +} + +interface DownloadCompleteEvent { + taskId: number; + filePath: string; + url: string; +} + +interface DownloadErrorEvent { + taskId: number; + error: string; +} + +interface DownloadStartedEvent { + taskId: number; + url: string; +} + +interface ActiveDownload { + taskId: number; + url: string; + state: 'running' | 'suspended' | 'canceling' | 'completed' | 'unknown'; +} +``` + +## Implementation Details + +### iOS Background Downloads + +- Uses `NSURLSession` with background configuration +- Session identifier: `com.fredrikburmester.streamyfin.backgrounddownloader` +- Downloads continue when app is backgrounded or suspended +- System may terminate downloads if app is force-quit + +### Android Background Downloads + +- Uses Android's `DownloadManager` API +- Downloads are managed by the system and continue in the background +- Shows download notification in the notification tray +- Downloads continue even if the app is closed +- Requires `INTERNET` permission (automatically added by Expo) + +### Background Modes + +The app's `Info.plist` already includes the required background mode for iOS: + +- `UIBackgroundModes`: `["audio", "fetch"]` + +### File Storage + +**iOS:** By default, downloaded files are saved to the app's Documents directory. + +**Android:** By default, files are saved to the app's external files directory (accessible via `FileSystem.documentDirectory` in Expo). + +You can specify a custom path using the `destinationPath` parameter on both platforms. + +## Building + +After adding this module, rebuild the app: + +```bash +# iOS +npx expo prebuild -p ios +npx expo run:ios + +# Android +npx expo prebuild -p android +npx expo run:android +``` + +Or install manually: + +```bash +# iOS +cd ios +pod install +cd .. + +# Android - prebuild handles everything +npx expo prebuild -p android +``` + +## Notes + +- Background downloads may be cancelled if the user force-quits the app (iOS) +- The OS manages download priority and may pause downloads to save battery +- Android shows a system notification for ongoing downloads +- Downloads over cellular are allowed by default on both platforms diff --git a/modules/background-downloader/android/build.gradle b/modules/background-downloader/android/build.gradle new file mode 100644 index 00000000..1b273d72 --- /dev/null +++ b/modules/background-downloader/android/build.gradle @@ -0,0 +1,46 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' +} + +group = 'expo.modules.backgrounddownloader' +version = '1.0.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25' + +apply from: expoModulesCorePlugin + +applyKotlinExpoModulesCorePlugin() +useDefaultAndroidSdkVersions() +useCoreDependencies() +useExpoPublishing() + +android { + namespace "expo.modules.backgrounddownloader" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + lintOptions { + abortOnError false + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" + implementation "com.squareup.okhttp3:okhttp:4.12.0" +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = "17" + } +} + diff --git a/modules/background-downloader/android/src/main/AndroidManifest.xml b/modules/background-downloader/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..44554032 --- /dev/null +++ b/modules/background-downloader/android/src/main/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/BackgroundDownloaderModule.kt b/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/BackgroundDownloaderModule.kt new file mode 100644 index 00000000..a2913b20 --- /dev/null +++ b/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/BackgroundDownloaderModule.kt @@ -0,0 +1,300 @@ +package expo.modules.backgrounddownloader + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log +import expo.modules.kotlin.Promise +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +data class DownloadTaskInfo( + val url: String, + val destinationPath: String? +) + +class BackgroundDownloaderModule : Module() { + companion object { + private const val TAG = "BackgroundDownloader" + } + + private val context + get() = requireNotNull(appContext.reactContext) + + private val downloadManager = OkHttpDownloadManager() + private val downloadTasks = mutableMapOf() + private val downloadQueue = mutableListOf>() + private var taskIdCounter = 1 + private var downloadService: DownloadService? = null + private var serviceBound = false + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + Log.d(TAG, "Service connected") + val binder = service as DownloadService.DownloadServiceBinder + downloadService = binder.getService() + serviceBound = true + } + + override fun onServiceDisconnected(name: ComponentName?) { + Log.d(TAG, "Service disconnected") + downloadService = null + serviceBound = false + } + } + + override fun definition() = ModuleDefinition { + Name("BackgroundDownloader") + + Events( + "onDownloadProgress", + "onDownloadComplete", + "onDownloadError", + "onDownloadStarted" + ) + + OnCreate { + Log.d(TAG, "Module created") + } + + OnDestroy { + Log.d(TAG, "Module destroyed") + downloadManager.cancelAllDownloads() + if (serviceBound) { + try { + context.unbindService(serviceConnection) + serviceBound = false + } catch (e: Exception) { + Log.e(TAG, "Error unbinding service: ${e.message}") + } + } + } + + AsyncFunction("startDownload") { urlString: String, destinationPath: String?, promise: Promise -> + try { + val taskId = startDownloadInternal(urlString, destinationPath) + promise.resolve(taskId) + } catch (e: Exception) { + promise.reject("DOWNLOAD_ERROR", "Failed to start download: ${e.message}", e) + } + } + + AsyncFunction("enqueueDownload") { urlString: String, destinationPath: String?, promise: Promise -> + try { + Log.d(TAG, "Enqueuing download: url=$urlString") + + // Add to queue + val wasEmpty = downloadQueue.isEmpty() + downloadQueue.add(Pair(urlString, destinationPath)) + Log.d(TAG, "Queue size: ${downloadQueue.size}") + + // If queue was empty and no active downloads, start processing immediately + if (wasEmpty && downloadTasks.isEmpty()) { + val taskId = processNextInQueue() + promise.resolve(taskId) + } else { + // Return placeholder taskId for queued items + promise.resolve(-1) + } + } catch (e: Exception) { + promise.reject("DOWNLOAD_ERROR", "Failed to enqueue download: ${e.message}", e) + } + } + + Function("cancelDownload") { taskId: Int -> + Log.d(TAG, "Cancelling download: taskId=$taskId") + downloadManager.cancelDownload(taskId) + downloadTasks.remove(taskId) + downloadService?.stopDownload() + + // Process next item in queue after cancellation + processNextInQueue() + } + + Function("cancelQueuedDownload") { url: String -> + // Remove from queue by URL + downloadQueue.removeAll { queuedItem -> + queuedItem.first == url + } + Log.d(TAG, "Removed queued download: $url, queue size: ${downloadQueue.size}") + } + + Function("cancelAllDownloads") { + Log.d(TAG, "Cancelling all downloads") + downloadManager.cancelAllDownloads() + downloadTasks.clear() + downloadQueue.clear() + stopDownloadService() + } + + AsyncFunction("getActiveDownloads") { promise: Promise -> + try { + val activeDownloads = downloadTasks.map { (taskId, taskInfo) -> + mapOf( + "taskId" to taskId, + "url" to taskInfo.url + ) + } + promise.resolve(activeDownloads) + } catch (e: Exception) { + promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e) + } + } + } + + private fun startDownloadInternal(urlString: String, destinationPath: String?): Int { + val taskId = taskIdCounter++ + + if (destinationPath == null) { + throw IllegalArgumentException("Destination path is required") + } + + downloadTasks[taskId] = DownloadTaskInfo( + url = urlString, + destinationPath = destinationPath + ) + + // Start foreground service if not running + startDownloadService() + downloadService?.startDownload() + + Log.d(TAG, "Starting download: taskId=$taskId, url=$urlString") + + // Send started event + sendEvent("onDownloadStarted", mapOf( + "taskId" to taskId, + "url" to urlString + )) + + // Start the download with OkHttp + downloadManager.startDownload( + taskId = taskId, + url = urlString, + destinationPath = destinationPath, + onProgress = { bytesWritten, totalBytes -> + handleProgress(taskId, bytesWritten, totalBytes) + }, + onComplete = { filePath -> + handleDownloadComplete(taskId, filePath) + }, + onError = { error -> + handleError(taskId, error) + } + ) + + return taskId + } + + private fun processNextInQueue(): Int { + // Check if queue has items + if (downloadQueue.isEmpty()) { + Log.d(TAG, "Queue is empty") + return -1 + } + + // Check if there are active downloads (one at a time) + if (downloadTasks.isNotEmpty()) { + Log.d(TAG, "Active downloads in progress (${downloadTasks.size}), waiting...") + return -1 + } + + // Get next item from queue + val (url, destinationPath) = downloadQueue.removeAt(0) + Log.d(TAG, "Processing next in queue: $url") + + return try { + startDownloadInternal(url, destinationPath) + } catch (e: Exception) { + Log.e(TAG, "Error processing queue item: ${e.message}", e) + // Try to process next item + processNextInQueue() + } + } + + private fun handleProgress(taskId: Int, bytesWritten: Long, totalBytes: Long) { + val progress = if (totalBytes > 0) { + bytesWritten.toDouble() / totalBytes.toDouble() + } else { + 0.0 + } + + // Update notification + val taskInfo = downloadTasks[taskId] + if (taskInfo != null) { + val progressPercent = (progress * 100).toInt() + downloadService?.updateProgress("Downloading video", progressPercent) + } + + sendEvent("onDownloadProgress", mapOf( + "taskId" to taskId, + "bytesWritten" to bytesWritten, + "totalBytes" to totalBytes, + "progress" to progress + )) + } + + private fun handleDownloadComplete(taskId: Int, filePath: String) { + val taskInfo = downloadTasks[taskId] + + if (taskInfo == null) { + Log.e(TAG, "Download completed but task info not found: taskId=$taskId") + return + } + + Log.d(TAG, "Download completed: taskId=$taskId, filePath=$filePath") + + sendEvent("onDownloadComplete", mapOf( + "taskId" to taskId, + "filePath" to filePath, + "url" to taskInfo.url + )) + + downloadTasks.remove(taskId) + downloadService?.stopDownload() + + // Process next item in queue + processNextInQueue() + } + + private fun handleError(taskId: Int, error: String) { + val taskInfo = downloadTasks[taskId] + + Log.e(TAG, "Download error: taskId=$taskId, error=$error") + + sendEvent("onDownloadError", mapOf( + "taskId" to taskId, + "error" to error + )) + + downloadTasks.remove(taskId) + downloadService?.stopDownload() + + // Process next item in queue even on error + processNextInQueue() + } + + private fun startDownloadService() { + if (!serviceBound) { + val intent = Intent(context, DownloadService::class.java) + context.startForegroundService(intent) + context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + } + } + + private fun stopDownloadService() { + if (serviceBound && downloadTasks.isEmpty()) { + try { + context.unbindService(serviceConnection) + serviceBound = false + downloadService = null + + val intent = Intent(context, DownloadService::class.java) + context.stopService(intent) + } catch (e: Exception) { + Log.e(TAG, "Error stopping service: ${e.message}") + } + } + } +} diff --git a/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/DownloadService.kt b/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/DownloadService.kt new file mode 100644 index 00000000..ed05fc5c --- /dev/null +++ b/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/DownloadService.kt @@ -0,0 +1,111 @@ +package expo.modules.backgrounddownloader + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat + +class DownloadService : Service() { + private val TAG = "DownloadService" + private val NOTIFICATION_ID = 1001 + private val CHANNEL_ID = "download_channel" + + private val binder = DownloadServiceBinder() + private var activeDownloadCount = 0 + private var currentDownloadTitle = "Preparing download..." + private var currentProgress = 0 + + inner class DownloadServiceBinder : Binder() { + fun getService(): DownloadService = this@DownloadService + } + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "DownloadService created") + createNotificationChannel() + } + + override fun onBind(intent: Intent?): IBinder { + Log.d(TAG, "DownloadService bound") + return binder + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "DownloadService started") + startForeground(NOTIFICATION_ID, createNotification()) + return START_STICKY + } + + override fun onDestroy() { + Log.d(TAG, "DownloadService destroyed") + super.onDestroy() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Downloads", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Video download progress" + setShowBadge(false) + } + + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + } + + private fun createNotification(): Notification { + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(currentDownloadTitle) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .setOnlyAlertOnce(true) + + if (currentProgress > 0) { + builder.setProgress(100, currentProgress, false) + .setContentText("$currentProgress% complete") + } else { + builder.setProgress(100, 0, true) + .setContentText("Starting...") + } + + return builder.build() + } + + fun startDownload() { + activeDownloadCount++ + Log.d(TAG, "Download started, active count: $activeDownloadCount") + if (activeDownloadCount == 1) { + startForeground(NOTIFICATION_ID, createNotification()) + } + } + + fun stopDownload() { + activeDownloadCount = maxOf(0, activeDownloadCount - 1) + Log.d(TAG, "Download stopped, active count: $activeDownloadCount") + if (activeDownloadCount == 0) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + + fun updateProgress(title: String, progress: Int) { + currentDownloadTitle = title + currentProgress = progress + + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.notify(NOTIFICATION_ID, createNotification()) + } +} + + diff --git a/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/OkHttpDownloadManager.kt b/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/OkHttpDownloadManager.kt new file mode 100644 index 00000000..f5d1f1a1 --- /dev/null +++ b/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/OkHttpDownloadManager.kt @@ -0,0 +1,151 @@ +package expo.modules.backgrounddownloader + +import android.util.Log +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit + +class OkHttpDownloadManager { + private val TAG = "OkHttpDownloadManager" + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .callTimeout(0, TimeUnit.SECONDS) // No timeout for long transcodes + .build() + + private val activeDownloads = mutableMapOf() + + fun startDownload( + taskId: Int, + url: String, + destinationPath: String, + onProgress: (bytesWritten: Long, totalBytes: Long) -> Unit, + onComplete: (filePath: String) -> Unit, + onError: (error: String) -> Unit + ) { + Log.d(TAG, "Starting download: taskId=$taskId, url=$url") + + val request = Request.Builder() + .url(url) + .build() + + val call = client.newCall(request) + activeDownloads[taskId] = call + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(TAG, "Download failed: taskId=$taskId, error=${e.message}") + activeDownloads.remove(taskId) + if (call.isCanceled()) { + // Don't report cancellation as error + return + } + onError(e.message ?: "Download failed") + } + + override fun onResponse(call: Call, response: Response) { + if (!response.isSuccessful) { + Log.e(TAG, "Download failed with HTTP code: ${response.code}") + activeDownloads.remove(taskId) + onError("HTTP error: ${response.code} ${response.message}") + return + } + + try { + val totalBytes = response.body?.contentLength() ?: -1L + val inputStream = response.body?.byteStream() + + if (inputStream == null) { + activeDownloads.remove(taskId) + onError("Failed to get response body") + return + } + + // Create destination directory if needed + val destFile = File(destinationPath) + val destDir = destFile.parentFile + if (destDir != null && !destDir.exists()) { + destDir.mkdirs() + } + + val outputStream = destFile.outputStream() + val buffer = ByteArray(8192) + var bytesWritten = 0L + var lastProgressUpdate = System.currentTimeMillis() + + inputStream.use { input -> + outputStream.use { output -> + var bytes = input.read(buffer) + while (bytes >= 0) { + // Check if download was cancelled + if (call.isCanceled()) { + Log.d(TAG, "Download cancelled: taskId=$taskId") + destFile.delete() + activeDownloads.remove(taskId) + return + } + + output.write(buffer, 0, bytes) + bytesWritten += bytes + + // Throttle progress updates to every 500ms + val now = System.currentTimeMillis() + if (now - lastProgressUpdate >= 500) { + onProgress(bytesWritten, totalBytes) + lastProgressUpdate = now + } + + bytes = input.read(buffer) + } + } + } + + // Send final progress update + onProgress(bytesWritten, totalBytes) + + Log.d(TAG, "Download completed: taskId=$taskId, bytes=$bytesWritten") + activeDownloads.remove(taskId) + onComplete(destinationPath) + + } catch (e: Exception) { + Log.e(TAG, "Error during download: taskId=$taskId, error=${e.message}", e) + activeDownloads.remove(taskId) + + // Clean up partial file + try { + File(destinationPath).delete() + } catch (deleteError: Exception) { + Log.e(TAG, "Failed to delete partial file: ${deleteError.message}") + } + + if (!call.isCanceled()) { + onError(e.message ?: "Download failed") + } + } + } + }) + } + + fun cancelDownload(taskId: Int) { + Log.d(TAG, "Cancelling download: taskId=$taskId") + activeDownloads[taskId]?.cancel() + activeDownloads.remove(taskId) + } + + fun cancelAllDownloads() { + Log.d(TAG, "Cancelling all downloads") + activeDownloads.values.forEach { it.cancel() } + activeDownloads.clear() + } + + fun hasActiveDownloads(): Boolean { + return activeDownloads.isNotEmpty() + } +} + diff --git a/modules/background-downloader/example.ts b/modules/background-downloader/example.ts new file mode 100644 index 00000000..51dd15af --- /dev/null +++ b/modules/background-downloader/example.ts @@ -0,0 +1,98 @@ +import type { + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadProgressEvent, +} from "@/modules"; +import { BackgroundDownloader } from "@/modules"; + +export class DownloadManager { + private progressSubscription: any; + private completeSubscription: any; + private errorSubscription: any; + private activeDownloads = new Map< + number, + { url: string; progress: number } + >(); + + constructor() { + this.setupListeners(); + } + + private setupListeners() { + this.progressSubscription = BackgroundDownloader.addProgressListener( + (event: DownloadProgressEvent) => { + const download = this.activeDownloads.get(event.taskId); + if (download) { + download.progress = event.progress; + console.log( + `Download ${event.taskId}: ${Math.floor(event.progress * 100)}%`, + ); + } + }, + ); + + this.completeSubscription = BackgroundDownloader.addCompleteListener( + (event: DownloadCompleteEvent) => { + console.log("Download complete:", event.filePath); + this.activeDownloads.delete(event.taskId); + }, + ); + + this.errorSubscription = BackgroundDownloader.addErrorListener( + (event: DownloadErrorEvent) => { + console.error("Download error:", event.error); + this.activeDownloads.delete(event.taskId); + }, + ); + } + + async startDownload(url: string, destinationPath?: string): Promise { + const taskId = await BackgroundDownloader.startDownload( + url, + destinationPath, + ); + this.activeDownloads.set(taskId, { url, progress: 0 }); + return taskId; + } + + cancelDownload(taskId: number): void { + BackgroundDownloader.cancelDownload(taskId); + this.activeDownloads.delete(taskId); + } + + cancelAllDownloads(): void { + BackgroundDownloader.cancelAllDownloads(); + this.activeDownloads.clear(); + } + + async getActiveDownloads() { + return await BackgroundDownloader.getActiveDownloads(); + } + + cleanup(): void { + this.progressSubscription?.remove(); + this.completeSubscription?.remove(); + this.errorSubscription?.remove(); + } +} + +const downloadManager = new DownloadManager(); + +export async function downloadFile( + url: string, + destinationPath?: string, +): Promise { + return await downloadManager.startDownload(url, destinationPath); +} + +export function cancelDownload(taskId: number): void { + downloadManager.cancelDownload(taskId); +} + +export function cancelAllDownloads(): void { + downloadManager.cancelAllDownloads(); +} + +export async function getActiveDownloads() { + return await downloadManager.getActiveDownloads(); +} diff --git a/modules/background-downloader/expo-module.config.json b/modules/background-downloader/expo-module.config.json new file mode 100644 index 00000000..f588b229 --- /dev/null +++ b/modules/background-downloader/expo-module.config.json @@ -0,0 +1,12 @@ +{ + "name": "background-downloader", + "version": "1.0.0", + "platforms": ["ios", "android"], + "ios": { + "modules": ["BackgroundDownloaderModule"], + "appDelegateSubscribers": ["BackgroundDownloaderAppDelegate"] + }, + "android": { + "modules": ["expo.modules.backgrounddownloader.BackgroundDownloaderModule"] + } +} diff --git a/modules/background-downloader/index.ts b/modules/background-downloader/index.ts new file mode 100644 index 00000000..93acce01 --- /dev/null +++ b/modules/background-downloader/index.ts @@ -0,0 +1,109 @@ +import type { EventSubscription } from "expo-modules-core"; +import type { + ActiveDownload, + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadProgressEvent, + DownloadStartedEvent, +} from "./src/BackgroundDownloader.types"; +import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule"; + +export interface BackgroundDownloader { + startDownload(url: string, destinationPath?: string): Promise; + enqueueDownload(url: string, destinationPath?: string): Promise; + cancelDownload(taskId: number): void; + cancelQueuedDownload(url: string): void; + cancelAllDownloads(): void; + getActiveDownloads(): Promise; + + addProgressListener( + listener: (event: DownloadProgressEvent) => void, + ): EventSubscription; + + addCompleteListener( + listener: (event: DownloadCompleteEvent) => void, + ): EventSubscription; + + addErrorListener( + listener: (event: DownloadErrorEvent) => void, + ): EventSubscription; + + addStartedListener( + listener: (event: DownloadStartedEvent) => void, + ): EventSubscription; +} + +const BackgroundDownloader: BackgroundDownloader = { + async startDownload(url: string, destinationPath?: string): Promise { + return await BackgroundDownloaderModule.startDownload(url, destinationPath); + }, + + async enqueueDownload( + url: string, + destinationPath?: string, + ): Promise { + return await BackgroundDownloaderModule.enqueueDownload( + url, + destinationPath, + ); + }, + + cancelDownload(taskId: number): void { + BackgroundDownloaderModule.cancelDownload(taskId); + }, + + cancelQueuedDownload(url: string): void { + BackgroundDownloaderModule.cancelQueuedDownload(url); + }, + + cancelAllDownloads(): void { + BackgroundDownloaderModule.cancelAllDownloads(); + }, + + async getActiveDownloads(): Promise { + return await BackgroundDownloaderModule.getActiveDownloads(); + }, + + addProgressListener( + listener: (event: DownloadProgressEvent) => void, + ): EventSubscription { + return BackgroundDownloaderModule.addListener( + "onDownloadProgress", + listener, + ); + }, + + addCompleteListener( + listener: (event: DownloadCompleteEvent) => void, + ): EventSubscription { + return BackgroundDownloaderModule.addListener( + "onDownloadComplete", + listener, + ); + }, + + addErrorListener( + listener: (event: DownloadErrorEvent) => void, + ): EventSubscription { + return BackgroundDownloaderModule.addListener("onDownloadError", listener); + }, + + addStartedListener( + listener: (event: DownloadStartedEvent) => void, + ): EventSubscription { + return BackgroundDownloaderModule.addListener( + "onDownloadStarted", + listener, + ); + }, +}; + +export default BackgroundDownloader; + +export type { + ActiveDownload, + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadProgressEvent, + DownloadStartedEvent, +}; diff --git a/modules/background-downloader/ios/BackgroundDownloader.podspec b/modules/background-downloader/ios/BackgroundDownloader.podspec new file mode 100644 index 00000000..b1d778a1 --- /dev/null +++ b/modules/background-downloader/ios/BackgroundDownloader.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'BackgroundDownloader' + s.version = '1.0.0' + s.summary = 'Background file downloader for iOS' + s.description = 'Native iOS module for downloading large files in the background using NSURLSession' + s.author = '' + s.homepage = 'https://docs.expo.dev/modules/' + s.platforms = { :ios => '15.6', :tvos => '15.0' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end + diff --git a/modules/background-downloader/ios/BackgroundDownloaderAppDelegate.swift b/modules/background-downloader/ios/BackgroundDownloaderAppDelegate.swift new file mode 100644 index 00000000..d34f97ff --- /dev/null +++ b/modules/background-downloader/ios/BackgroundDownloaderAppDelegate.swift @@ -0,0 +1,15 @@ +import ExpoModulesCore +import UIKit + +public class BackgroundDownloaderAppDelegate: ExpoAppDelegateSubscriber { + public func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + if identifier == "com.fredrikburmester.streamyfin.backgrounddownloader" { + BackgroundDownloaderModule.setBackgroundCompletionHandler(completionHandler) + } + } +} + diff --git a/modules/background-downloader/ios/BackgroundDownloaderModule.swift b/modules/background-downloader/ios/BackgroundDownloaderModule.swift new file mode 100644 index 00000000..4e50c723 --- /dev/null +++ b/modules/background-downloader/ios/BackgroundDownloaderModule.swift @@ -0,0 +1,397 @@ +import ExpoModulesCore +import Foundation + +enum DownloadError: Error { + case invalidURL + case fileOperationFailed + case downloadFailed +} + +struct DownloadTaskInfo { + let url: String + let destinationPath: String? +} + +// Separate delegate class to handle URLSession callbacks +class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate { + weak var module: BackgroundDownloaderModule? + + init(module: BackgroundDownloaderModule) { + self.module = module + super.init() + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + module?.handleProgress( + taskId: downloadTask.taskIdentifier, + bytesWritten: totalBytesWritten, + totalBytes: totalBytesExpectedToWrite + ) + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + module?.handleDownloadComplete( + taskId: downloadTask.taskIdentifier, + location: location, + downloadTask: downloadTask + ) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + if let error = error { + print("[BackgroundDownloader] Task \(task.taskIdentifier) error: \(error.localizedDescription)") + module?.handleError(taskId: task.taskIdentifier, error: error) + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + DispatchQueue.main.async { + if let completion = BackgroundDownloaderModule.backgroundCompletionHandler { + completion() + BackgroundDownloaderModule.backgroundCompletionHandler = nil + } + } + } +} + +public class BackgroundDownloaderModule: Module { + private var session: URLSession? + private var sessionDelegate: DownloadSessionDelegate? + fileprivate static var backgroundCompletionHandler: (() -> Void)? + private var downloadTasks: [Int: DownloadTaskInfo] = [:] + private var downloadQueue: [(url: String, destinationPath: String?)] = [] + private var lastProgressTime: [Int: Date] = [:] + + public func definition() -> ModuleDefinition { + Name("BackgroundDownloader") + + Events( + "onDownloadProgress", + "onDownloadComplete", + "onDownloadError", + "onDownloadStarted" + ) + + OnCreate { + self.initializeSession() + } + + AsyncFunction("startDownload") { (urlString: String, destinationPath: String?) -> Int in + guard let url = URL(string: urlString) else { + throw DownloadError.invalidURL + } + + if self.session == nil { + self.initializeSession() + } + + guard let session = self.session else { + throw DownloadError.downloadFailed + } + + // Create a URLRequest to ensure proper handling + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 300 + + let task = session.downloadTask(with: request) + let taskId = task.taskIdentifier + + self.downloadTasks[taskId] = DownloadTaskInfo( + url: urlString, + destinationPath: destinationPath + ) + + task.resume() + + self.sendEvent("onDownloadStarted", [ + "taskId": taskId, + "url": urlString + ]) + + return taskId + } + + AsyncFunction("enqueueDownload") { (urlString: String, destinationPath: String?) -> Int in + // Add to queue + let wasEmpty = self.downloadQueue.isEmpty + self.downloadQueue.append((url: urlString, destinationPath: destinationPath)) + + // If queue was empty and no active downloads, start processing immediately + if wasEmpty { + return try await self.processNextInQueue() + } + + // Return placeholder taskId for queued items + return -1 + } + + Function("cancelDownload") { (taskId: Int) in + self.session?.getAllTasks { tasks in + for task in tasks where task.taskIdentifier == taskId { + task.cancel() + self.downloadTasks.removeValue(forKey: taskId) + } + } + } + + Function("cancelQueuedDownload") { (url: String) in + // Remove from queue by URL + self.downloadQueue.removeAll { queuedItem in + queuedItem.url == url + } + } + + Function("cancelAllDownloads") { + self.session?.getAllTasks { tasks in + for task in tasks { + task.cancel() + } + self.downloadTasks.removeAll() + } + } + + AsyncFunction("getActiveDownloads") { () -> [[String: Any]] in + return try await withCheckedThrowingContinuation { continuation in + let downloadTasks = self.downloadTasks + + self.session?.getAllTasks { tasks in + let activeDownloads = tasks.compactMap { task -> [String: Any]? in + guard task is URLSessionDownloadTask, + let info = downloadTasks[task.taskIdentifier] else { + return nil + } + + return [ + "taskId": task.taskIdentifier, + "url": info.url + ] + } + continuation.resume(returning: activeDownloads) + } + } + } + } + + private func initializeSession() { + print("[BackgroundDownloader] Initializing URLSession") + + let config = URLSessionConfiguration.background( + withIdentifier: "com.fredrikburmester.streamyfin.backgrounddownloader" + ) + config.allowsCellularAccess = true + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + + self.sessionDelegate = DownloadSessionDelegate(module: self) + self.session = URLSession( + configuration: config, + delegate: self.sessionDelegate, + delegateQueue: nil + ) + + print("[BackgroundDownloader] URLSession initialized with delegate: \(String(describing: self.sessionDelegate))") + print("[BackgroundDownloader] Session identifier: \(config.identifier ?? "nil")") + print("[BackgroundDownloader] Delegate queue: nil (uses default)") + + // Verify delegate is connected + if let session = self.session, session.delegate != nil { + print("[BackgroundDownloader] ✅ Delegate successfully attached to session") + } else { + print("[BackgroundDownloader] ⚠️ DELEGATE NOT ATTACHED!") + } + } + + // Handler methods called by the delegate + func handleProgress(taskId: Int, bytesWritten: Int64, totalBytes: Int64) { + let progress = totalBytes > 0 + ? Double(bytesWritten) / Double(totalBytes) + : 0.0 + + // Throttle progress updates: only send every 500ms + let lastTime = lastProgressTime[taskId] ?? Date.distantPast + let now = Date() + let timeDiff = now.timeIntervalSince(lastTime) + + // Send if 500ms passed + if timeDiff >= 0.5 { + self.sendEvent("onDownloadProgress", [ + "taskId": taskId, + "bytesWritten": bytesWritten, + "totalBytes": totalBytes, + "progress": progress + ]) + + lastProgressTime[taskId] = now + } + } + + func handleDownloadComplete(taskId: Int, location: URL, downloadTask: URLSessionDownloadTask) { + guard let taskInfo = downloadTasks[taskId] else { + self.sendEvent("onDownloadError", [ + "taskId": taskId, + "error": "Download task info not found" + ]) + return + } + + let fileManager = FileManager.default + + do { + let destinationURL: URL + + if let customPath = taskInfo.destinationPath { + destinationURL = URL(fileURLWithPath: customPath) + } else { + let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let filename = downloadTask.response?.suggestedFilename + ?? downloadTask.originalRequest?.url?.lastPathComponent + ?? "download_\(taskId)" + destinationURL = documentsDir.appendingPathComponent(filename) + } + + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + + let destinationDirectory = destinationURL.deletingLastPathComponent() + if !fileManager.fileExists(atPath: destinationDirectory.path) { + try fileManager.createDirectory( + at: destinationDirectory, + withIntermediateDirectories: true, + attributes: nil + ) + } + + try fileManager.moveItem(at: location, to: destinationURL) + + self.sendEvent("onDownloadComplete", [ + "taskId": taskId, + "filePath": destinationURL.path, + "url": taskInfo.url + ]) + + downloadTasks.removeValue(forKey: taskId) + lastProgressTime.removeValue(forKey: taskId) + + // Process next item in queue + Task { + do { + _ = try await self.processNextInQueue() + } catch { + print("[BackgroundDownloader] Error processing next: \(error)") + } + } + + } catch { + self.sendEvent("onDownloadError", [ + "taskId": taskId, + "error": "File operation failed: \(error.localizedDescription)" + ]) + + // Process next item in queue even on error + Task { + do { + _ = try await self.processNextInQueue() + } catch { + print("[BackgroundDownloader] Error processing next: \(error)") + } + } + } + } + + func handleError(taskId: Int, error: Error) { + let isCancelled = (error as NSError).code == NSURLErrorCancelled + + if !isCancelled { + print("[BackgroundDownloader] Task \(taskId) error: \(error.localizedDescription)") + + self.sendEvent("onDownloadError", [ + "taskId": taskId, + "error": error.localizedDescription + ]) + } + + downloadTasks.removeValue(forKey: taskId) + lastProgressTime.removeValue(forKey: taskId) + + // Process next item in queue (whether cancelled or errored) + Task { + do { + _ = try await self.processNextInQueue() + } catch { + print("[BackgroundDownloader] Error processing next: \(error)") + } + } + } + + private func processNextInQueue() async throws -> Int { + // Check if queue has items + guard !downloadQueue.isEmpty else { + return -1 + } + + // Check if there are active downloads + if !downloadTasks.isEmpty { + return -1 + } + + // Get next item from queue + let (url, destinationPath) = downloadQueue.removeFirst() + print("[BackgroundDownloader] Starting queued download") + + // Start the download using existing startDownload logic + guard let urlObj = URL(string: url) else { + print("[BackgroundDownloader] Invalid URL in queue: \(url)") + return try await processNextInQueue() + } + + if session == nil { + initializeSession() + } + + guard let session = self.session else { + throw DownloadError.downloadFailed + } + + var request = URLRequest(url: urlObj) + request.httpMethod = "GET" + request.timeoutInterval = 300 + + let task = session.downloadTask(with: request) + let taskId = task.taskIdentifier + + downloadTasks[taskId] = DownloadTaskInfo( + url: url, + destinationPath: destinationPath + ) + + task.resume() + + sendEvent("onDownloadStarted", [ + "taskId": taskId, + "url": url + ]) + + return taskId + } + + static func setBackgroundCompletionHandler(_ handler: @escaping () -> Void) { + BackgroundDownloaderModule.backgroundCompletionHandler = handler + } +} + diff --git a/modules/background-downloader/src/BackgroundDownloader.types.ts b/modules/background-downloader/src/BackgroundDownloader.types.ts new file mode 100644 index 00000000..566aa5e7 --- /dev/null +++ b/modules/background-downloader/src/BackgroundDownloader.types.ts @@ -0,0 +1,41 @@ +import type { EventSubscription } from "expo-modules-core"; + +export interface DownloadProgressEvent { + taskId: number; + bytesWritten: number; + totalBytes: number; + progress: number; +} + +export interface DownloadCompleteEvent { + taskId: number; + filePath: string; + url: string; +} + +export interface DownloadErrorEvent { + taskId: number; + error: string; +} + +export interface DownloadStartedEvent { + taskId: number; + url: string; +} + +export interface ActiveDownload { + taskId: number; + url: string; + state: "running" | "suspended" | "canceling" | "completed" | "unknown"; +} + +export interface BackgroundDownloaderModuleType { + startDownload(url: string, destinationPath?: string): Promise; + cancelDownload(taskId: number): void; + cancelAllDownloads(): void; + getActiveDownloads(): Promise; + addListener( + eventName: string, + listener: (event: any) => void, + ): EventSubscription; +} diff --git a/modules/background-downloader/src/BackgroundDownloaderModule.ts b/modules/background-downloader/src/BackgroundDownloaderModule.ts new file mode 100644 index 00000000..d2df92b8 --- /dev/null +++ b/modules/background-downloader/src/BackgroundDownloaderModule.ts @@ -0,0 +1,7 @@ +import { requireNativeModule } from "expo-modules-core"; +import type { BackgroundDownloaderModuleType } from "./BackgroundDownloader.types"; + +const BackgroundDownloaderModule: BackgroundDownloaderModuleType = + requireNativeModule("BackgroundDownloader"); + +export default BackgroundDownloaderModule; diff --git a/modules/index.ts b/modules/index.ts index aa4b7647..d0ea5cd2 100644 --- a/modules/index.ts +++ b/modules/index.ts @@ -12,6 +12,16 @@ import type { } from "./VlcPlayer.types"; import VlcPlayerView from "./VlcPlayerView"; +export type { + ActiveDownload, + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadProgressEvent, + DownloadStartedEvent, +} from "./background-downloader"; +// Background Downloader +export { default as BackgroundDownloader } from "./background-downloader"; + // Component export { VlcPlayerView }; diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt index c4517802..e164efb9 100644 --- a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt +++ b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt @@ -212,9 +212,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context fun setSource(source: Map) { log.debug("setting source $source") if (hasSource) { - log.debug("Source already set. Resuming") - mediaPlayer?.attachViews(videoLayout, null, false, false) - play() + log.debug("Source already set. Ignoring.") return } val mediaOptions = source["mediaOptions"] as? Map ?: emptyMap() diff --git a/package.json b/package.json index b021f8b7..c0f711f7 100644 --- a/package.json +++ b/package.json @@ -22,120 +22,118 @@ "test": "bun run typecheck && bun run lint && bun run format && bun run doctor" }, "dependencies": { - "@bottom-tabs/react-navigation": "^0.12.0", - "@expo/metro-runtime": "~5.0.5", + "@bottom-tabs/react-navigation": "^1.0.2", + "@expo/metro-runtime": "~6.1.1", "@expo/react-native-action-sheet": "^4.1.1", - "@expo/vector-icons": "^14.1.0", + "@expo/ui": "^0.2.0-beta.4", + "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "^5.1.0", - "@jellyfin/sdk": "^0.11.0", - "@kesha-antonov/react-native-background-downloader": "^3.2.6", + "@jellyfin/sdk": "^0.13.0", "@react-native-community/netinfo": "^11.4.1", - "@react-native-menu/menu": "1.2.3", "@react-navigation/material-top-tabs": "^7.2.14", "@react-navigation/native": "^7.0.14", - "@shopify/flash-list": "^1.8.3", + "@shopify/flash-list": "2.0.2", "@tanstack/react-query": "^5.66.0", "axios": "^1.7.9", - "expo": "^53.0.23", - "expo-application": "~6.1.4", - "expo-asset": "~11.1.7", - "expo-background-task": "~0.2.8", - "expo-blur": "~14.1.4", - "expo-brightness": "~13.1.4", - "expo-build-properties": "~0.14.6", - "expo-constants": "~17.1.5", - "expo-device": "~7.1.4", - "expo-font": "~13.3.1", - "expo-haptics": "~14.1.4", - "expo-image": "~2.4.0", - "expo-linear-gradient": "~14.1.4", - "expo-linking": "~7.1.4", - "expo-localization": "~16.1.5", - "expo-notifications": "~0.31.2", - "expo-router": "~5.1.7", - "expo-screen-orientation": "~8.1.6", - "expo-sensors": "~14.1.4", - "expo-sharing": "~13.1.5", - "expo-splash-screen": "~0.30.8", - "expo-status-bar": "~2.2.3", - "expo-system-ui": "~5.0.11", - "expo-task-manager": "~13.1.6", - "expo-web-browser": "~14.2.0", + "expo": "^54.0.23", + "expo-application": "~7.0.5", + "expo-asset": "~12.0.6", + "expo-background-task": "~1.0.5", + "expo-blur": "~15.0.5", + "expo-brightness": "~14.0.5", + "expo-build-properties": "~1.0.6", + "expo-constants": "~18.0.10", + "expo-dev-client": "~6.0.17", + "expo-device": "~8.0.5", + "expo-font": "~14.0.9", + "expo-haptics": "~15.0.5", + "expo-image": "~3.0.10", + "expo-linear-gradient": "~15.0.5", + "expo-linking": "~8.0.6", + "expo-localization": "~17.0.5", + "expo-notifications": "~0.32.7", + "expo-router": "~6.0.14", + "expo-screen-orientation": "~9.0.5", + "expo-sensors": "~15.0.5", + "expo-sharing": "~14.0.5", + "expo-splash-screen": "~31.0.7", + "expo-status-bar": "~3.0.6", + "expo-system-ui": "~6.0.8", + "expo-task-manager": "~14.0.8", + "expo-web-browser": "~15.0.9", "i18next": "^25.0.0", "jotai": "^2.12.5", "lodash": "^4.17.21", "nativewind": "^2.0.11", - "react": "19.0.0", - "react-dom": "19.0.0", + "patch-package": "^8.0.0", + "react": "19.1.0", + "react-dom": "19.1.0", "react-i18next": "^15.4.0", - "react-native": "npm:react-native-tvos@0.79.5-0", + "react-native": "npm:react-native-tvos@0.81.5-1", "react-native-awesome-slider": "^2.9.0", - "react-native-bottom-tabs": "^0.12.0", + "react-native-bottom-tabs": "^1.0.2", "react-native-circular-progress": "^1.4.1", "react-native-collapsible": "^1.6.2", "react-native-country-flag": "^2.0.2", "react-native-device-info": "^14.0.4", - "react-native-gesture-handler": "~2.24.0", - "react-native-google-cast": "^4.9.0", + "react-native-edge-to-edge": "^1.7.0", + "react-native-gesture-handler": "~2.28.0", + "react-native-google-cast": "^4.9.1", "react-native-image-colors": "^2.4.0", - "react-native-ios-context-menu": "^3.1.0", - "react-native-ios-utilities": "5.1.8", - "react-native-mmkv": "2.12.2", + "react-native-ios-context-menu": "^3.2.1", + "react-native-ios-utilities": "5.2.0", + "react-native-mmkv": "4.0.0", + "react-native-nitro-modules": "^0.31.5", "react-native-pager-view": "^6.9.1", - "react-native-reanimated": "~3.19.1", + "react-native-reanimated": "~4.1.1", "react-native-reanimated-carousel": "4.0.2", - "react-native-safe-area-context": "5.4.0", - "react-native-screens": "~4.11.1", - "react-native-svg": "15.11.2", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.18.0", + "react-native-svg": "15.12.1", "react-native-udp": "^4.1.7", "react-native-url-polyfill": "^2.0.0", "react-native-uuid": "^2.0.3", - "react-native-video": "6.14.1", + "react-native-video": "6.16.1", "react-native-volume-manager": "^2.0.8", - "react-native-web": "^0.20.0", + "react-native-web": "^0.21.0", + "react-native-worklets": "0.5.1", "sonner-native": "^0.21.0", "tailwindcss": "3.3.2", "use-debounce": "^10.0.4", - "zeego": "^3.0.6", "zod": "^4.1.3" }, "devDependencies": { - "@babel/core": "7.28.5", - "@biomejs/biome": "2.2.7", - "@react-native-community/cli": "20.0.2", - "@react-native-tvos/config-tv": "0.1.4", - "@types/jest": "30.0.0", - "@types/lodash": "4.17.20", - "@types/react": "~19.0.10", - "@types/react-test-renderer": "19.1.0", - "cross-env": "10.1.0", - "expo-dev-client": "5.2.4", - "expo-doctor": "1.17.11", - "husky": "9.1.7", - "lint-staged": "16.2.6", + "@babel/core": "^7.20.0", + "@biomejs/biome": "^2.2.4", + "@react-native-community/cli": "^20.0.0", + "@react-native-tvos/config-tv": "^0.1.1", + "@types/jest": "^29.5.12", + "@types/lodash": "^4.17.15", + "@types/react": "~19.1.10", + "@types/react-test-renderer": "^19.0.0", + "cross-env": "^10.0.0", + "expo-doctor": "^1.17.0", + "husky": "^9.1.7", + "lint-staged": "^16.1.5", + "postinstall-postinstall": "^2.1.0", "react-test-renderer": "19.1.1", - "typescript": "5.8.3" + "typescript": "~5.9.2" }, "expo": { - "install": { - "exclude": [ - "react-native", - "@shopify/flash-list", - "react-native-reanimated", - "react-native-pager-view" - ] - }, "doctor": { "reactNativeDirectoryCheck": { "exclude": [ "react-native-google-cast", "react-native-udp", - "@bottom-tabs/react-navigation", - "@jellyfin/sdk", - "expo-doctor" + "@jellyfin/sdk" ], "listUnknownPackages": false } + }, + "install": { + "exclude": [ + "react-native-screens" + ] } }, "private": true, @@ -149,5 +147,9 @@ }, "trustedDependencies": [ "unrs-resolver" - ] + ], + "resolutions": { + "expo-constants": "~18.0.10", + "expo-task-manager": "~14.0.8" + } } diff --git a/patches/@react-native-menu+menu+1.2.4.patch b/patches/@react-native-menu+menu+1.2.4.patch new file mode 100644 index 00000000..be8e245d --- /dev/null +++ b/patches/@react-native-menu+menu+1.2.4.patch @@ -0,0 +1,58 @@ +diff --git a/node_modules/@react-native-menu/menu/android/.DS_Store b/node_modules/@react-native-menu/menu/android/.DS_Store +new file mode 100644 +index 0000000..5008ddf +Binary files /dev/null and b/node_modules/@react-native-menu/menu/android/.DS_Store differ +diff --git a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt +index 17ed7c6..c45f5cc 100644 +--- a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt ++++ b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt +@@ -24,6 +24,11 @@ class MenuView(private val mContext: ReactContext) : ReactViewGroup(mContext) { + private var mIsOnLongPress = false + private var mGestureDetector: GestureDetector + private var mHitSlopRect: Rect? = null ++ set(value) { ++ super.hitSlopRect = value ++ mHitSlopRect = value ++ updateTouchDelegate() ++ } + + init { + mGestureDetector = GestureDetector(mContext, object : GestureDetector.SimpleOnGestureListener() { +@@ -47,12 +52,6 @@ class MenuView(private val mContext: ReactContext) : ReactViewGroup(mContext) { + prepareMenu() + } + +- override fun setHitSlopRect(rect: Rect?) { +- super.setHitSlopRect(rect) +- mHitSlopRect = rect +- updateTouchDelegate() +- } +- + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + return true + } +diff --git a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt +index 4731e1a..e4d2743 100644 +--- a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt ++++ b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt +@@ -123,9 +123,9 @@ abstract class MenuViewManagerBase : ReactClippingViewManager() { + fun setHitSlop(view: ReactViewGroup, @Nullable hitSlop: ReadableMap?) { + if (hitSlop == null) { + // We should keep using setters as `Val cannot be reassigned` +- view.setHitSlopRect(null) ++ view.hitSlopRect = null + } else { +- view.setHitSlopRect( ++ view.hitSlopRect = ( + Rect( + if (hitSlop.hasKey("left")) + PixelUtil.toPixelFromDIP(hitSlop.getDouble("left")).toInt() +@@ -206,7 +206,7 @@ abstract class MenuViewManagerBase : ReactClippingViewManager() { + + @ReactProp(name = ViewProps.OVERFLOW) + fun setOverflow(view: ReactViewGroup, overflow: String?) { +- view.setOverflow(overflow) ++ view.overflow = overflow + } + + @ReactProp(name = "backfaceVisibility") diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index ffb15e9e..4610c30b 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -1,325 +1,55 @@ -import type { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client/models"; import * as Application from "expo-application"; -import * as FileSystem from "expo-file-system"; -import * as Notifications from "expo-notifications"; -import { router } from "expo-router"; +import { Directory, Paths } from "expo-file-system"; import { atom, useAtom } from "jotai"; -import { throttle } from "lodash"; -import React, { - createContext, - useCallback, - useContext, - useEffect, - useMemo, -} from "react"; -import { useTranslation } from "react-i18next"; +import { createContext, useCallback, useContext, useMemo, useRef } from "react"; import { Platform } from "react-native"; -import { toast } from "sonner-native"; import { useHaptic } from "@/hooks/useHaptic"; -import useImageStorage from "@/hooks/useImageStorage"; -import { useInterval } from "@/hooks/useInterval"; -import { useSettings } from "@/utils/atoms/settings"; -import { getOrSetDeviceId } from "@/utils/device"; -import useDownloadHelper from "@/utils/download"; -import { getItemImage } from "@/utils/getItemImage"; -import { dumpDownloadDiagnostics, writeToLog } from "@/utils/log"; -import { storage } from "@/utils/mmkv"; -import { fetchAndParseSegments } from "@/utils/segments"; -import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay"; -import { Bitrate } from "../components/BitrateSelector"; import { - DownloadedItem, - DownloadsDatabase, - JobStatus, - TrickPlayData, -} from "./Downloads/types"; + getAllDownloadedItems, + getDownloadedItemById, + getDownloadsDatabase, +} from "./Downloads/database"; +import { getDownloadedItemSize } from "./Downloads/fileOperations"; +import { useDownloadEventHandlers } from "./Downloads/hooks/useDownloadEventHandlers"; +import { useDownloadOperations } from "./Downloads/hooks/useDownloadOperations"; +import type { JobStatus } from "./Downloads/types"; import { apiAtom } from "./JellyfinProvider"; -const BackGroundDownloader = !Platform.isTV - ? require("@kesha-antonov/react-native-background-downloader") - : null; - -// Cap progress at 99% to avoid showing 100% before the download is actually complete -const MAX_PROGRESS_BEFORE_COMPLETION = 99; - -// Estimate the total download size in bytes for a job. If the media source -// provides a Size, use that. Otherwise, if we have a bitrate and run time -// (RunTimeTicks), approximate size = (bitrate bits/sec * seconds) / 8. -const calculateEstimatedSize = (p: JobStatus): number => { - const size = p.mediaSource?.Size || 0; - const maxBitrate = p.maxBitrate?.value; - const runTimeTicks = (p.item?.RunTimeTicks || 0) as number; - - if (!size && maxBitrate && runTimeTicks > 0) { - // Jellyfin RunTimeTicks are in 10,000,000 ticks per second - const seconds = runTimeTicks / 10000000; - if (seconds > 0) { - // maxBitrate is in bits per second; convert to bytes - return Math.round((maxBitrate / 8) * seconds); - } - } - - return size || 0; -}; - -// Calculate download speed in bytes/sec based on a job's last update time -// and previously recorded bytesDownloaded. -const calculateSpeed = ( - p: JobStatus, - currentBytesDownloaded?: number, -): number | undefined => { - // Prefer session-only deltas when available: lastSessionBytes + lastSessionUpdateTime - const now = Date.now(); - - if (p.lastSessionUpdateTime && p.lastSessionBytes !== undefined) { - const last = new Date(p.lastSessionUpdateTime).getTime(); - const deltaTime = (now - last) / 1000; - if (deltaTime > 0) { - const current = - currentBytesDownloaded ?? p.bytesDownloaded ?? p.lastSessionBytes; - const deltaBytes = current - p.lastSessionBytes; - if (deltaBytes > 0) return deltaBytes / deltaTime; - } - } - - // Fallback to total-based deltas for compatibility - if (!p.lastProgressUpdateTime || p.bytesDownloaded === undefined) - return undefined; - const last = new Date(p.lastProgressUpdateTime).getTime(); - const deltaTime = (now - last) / 1000; - if (deltaTime <= 0) return undefined; - const prev = p.bytesDownloaded || 0; - const current = currentBytesDownloaded ?? prev; - const deltaBytes = current - prev; - if (deltaBytes <= 0) return undefined; - return deltaBytes / deltaTime; -}; - export const processesAtom = atom([]); -const DOWNLOADS_DATABASE_KEY = "downloads.v2.json"; +export const downloadsRefreshAtom = atom(0); const DownloadContext = createContext | null>(null); function useDownloadProvider() { - const { t } = useTranslation(); const [api] = useAtom(apiAtom); - const { saveSeriesPrimaryImage } = useDownloadHelper(); - const { saveImage } = useImageStorage(); const [processes, setProcesses] = useAtom(processesAtom); - const { settings } = useSettings(); + const [refreshKey, setRefreshKey] = useAtom(downloadsRefreshAtom); const successHapticFeedback = useHaptic("success"); - // Generate notification content based on item type - const getNotificationContent = useCallback( - (item: BaseItemDto, isSuccess: boolean) => { - if (item.Type === "Episode") { - const season = item.ParentIndexNumber - ? String(item.ParentIndexNumber).padStart(2, "0") - : "??"; - const episode = item.IndexNumber - ? String(item.IndexNumber).padStart(2, "0") - : "??"; - const subtitle = `${item.Name} - [S${season}E${episode}] (${item.SeriesName})`; + // Track task ID to process ID mapping + const taskMapRef = useRef>(new Map()); - return { - title: isSuccess ? "Download complete" : "Download failed", - body: subtitle, - }; - } else if (item.Type === "Movie") { - const year = item.ProductionYear ? ` (${item.ProductionYear})` : ""; - const subtitle = `${item.Name}${year}`; + // Reactive downloaded items that updates when refreshKey changes + const downloadedItems = useMemo(() => { + return getAllDownloadedItems(); + }, [refreshKey]); - return { - title: isSuccess ? "Download complete" : "Download failed", - body: subtitle, - }; - } else { - // Fallback for other types - return { - title: isSuccess - ? t("home.downloads.toasts.download_completed_for_item", { - item: item.Name, - }) - : t("home.downloads.toasts.download_failed_for_item", { - item: item.Name, - }), - body: item.Name || "Unknown item", - }; - } - }, - [t], + // Trigger refresh of download lists + const triggerRefresh = useCallback(() => { + setRefreshKey((prev) => prev + 1); + }, [setRefreshKey]); + + const authHeader = useMemo(() => { + return api?.accessToken; + }, [api]); + + const APP_CACHE_DOWNLOAD_DIRECTORY = new Directory( + Paths.cache, + `${Application.applicationId}/Downloads/`, ); - // Send local notification for download events - const sendDownloadNotification = useCallback( - async (title: string, body: string, data?: Record) => { - if (Platform.isTV) return; - - try { - await Notifications.scheduleNotificationAsync({ - content: { - title, - body, - data, - ...(Platform.OS === "android" && { channelId: "downloads" }), - }, - trigger: null, // Show immediately - }); - } catch (error) { - console.error("Failed to send notification:", error); - } - }, - [], - ); - - /// Cant use the background downloader callback. As its not triggered if size is unknown. - const updateProgress = async () => { - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - if (!tasks) { - return; - } - // check if processes are missing - setProcesses((processes) => { - const missingProcesses = tasks - .filter((t: any) => t.metadata && !processes.some((p) => p.id === t.id)) - .map((t: any) => { - return t.metadata as JobStatus; - }); - - const currentProcesses = [...processes, ...missingProcesses]; - const updatedProcesses = currentProcesses.map((p) => { - // Enhanced filtering to prevent iOS zombie task interference - // Only update progress for downloads that are actively downloading - if (p.status !== "downloading") { - return p; - } - - // Find task for this process - const task = tasks.find((s: any) => s.id === p.id); - if (!task) { - return p; // No task found, keep current state - } - - /* - // TODO: Uncomment this block to re-enable iOS zombie task detection - // iOS: Extra validation to prevent zombie task interference - if (Platform.OS === "ios") { - // Check if we have multiple tasks for same ID (zombie detection) - const tasksForId = tasks.filter((t: any) => t.id === p.id); - if (tasksForId.length > 1) { - console.warn( - `[UPDATE] Detected ${tasksForId.length} zombie tasks for ${p.id}, ignoring progress update`, - ); - return p; // Don't update progress from potentially conflicting tasks - } - - // If task state looks suspicious (e.g., iOS task stuck in background), be conservative - if ( - task.state && - ["SUSPENDED", "PAUSED"].includes(task.state) && - p.status === "downloading" - ) { - console.warn( - `[UPDATE] Task ${p.id} has suspicious state ${task.state}, ignoring progress update`, - ); - return p; - } - } - */ - - if (task && p.status === "downloading") { - const estimatedSize = calculateEstimatedSize(p); - let progress = p.progress; - - // If we have a pausedProgress snapshot then merge current session - // progress into it. We accept pausedProgress === 0 as valid because - // users can pause immediately after starting. - if (p.pausedProgress !== undefined) { - const totalBytesDownloaded = - (p.pausedBytes ?? 0) + task.bytesDownloaded; - - // Calculate progress based on total bytes downloaded vs estimated size - progress = - estimatedSize > 0 - ? (totalBytesDownloaded / estimatedSize) * 100 - : 0; - - // Use the total accounted bytes when computing speed so the - // displayed speed and progress remain consistent after resume. - const speed = calculateSpeed(p, totalBytesDownloaded); - - return { - ...p, - progress: Math.min(progress, MAX_PROGRESS_BEFORE_COMPLETION), - speed, - bytesDownloaded: totalBytesDownloaded, - lastProgressUpdateTime: new Date(), - estimatedTotalSizeBytes: estimatedSize, - // Set session bytes to total bytes downloaded - lastSessionBytes: totalBytesDownloaded, - lastSessionUpdateTime: new Date(), - }; - } else { - if (estimatedSize > 0) { - progress = (100 / estimatedSize) * task.bytesDownloaded; - } - if (progress >= 100) { - progress = MAX_PROGRESS_BEFORE_COMPLETION; - } - const speed = calculateSpeed(p, task.bytesDownloaded); - return { - ...p, - progress, - speed, - bytesDownloaded: task.bytesDownloaded, - lastProgressUpdateTime: new Date(), - estimatedTotalSizeBytes: estimatedSize, - lastSessionBytes: task.bytesDownloaded, - lastSessionUpdateTime: new Date(), - }; - } - } - return p; - }); - - return updatedProcesses; - }); - }; - - useInterval(updateProgress, 2000); - - const getDownloadedItemById = (id: string): DownloadedItem | undefined => { - const db = getDownloadsDatabase(); - - // Check movies first - if (db.movies[id]) { - return db.movies[id]; - } - - // Check episodes - for (const series of Object.values(db.series)) { - for (const season of Object.values(series.seasons)) { - for (const episode of Object.values(season.episodes)) { - if (episode.item.Id === id) { - return episode; - } - } - } - } - - // Check other media types - if (db.other[id]) { - return db.other[id]; - } - - return undefined; - }; - const updateProcess = useCallback( ( processId: string, @@ -342,908 +72,72 @@ function useDownloadProvider() { [setProcesses], ); - const authHeader = useMemo(() => { - return api?.accessToken; - }, [api]); - - const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`; - - const getDownloadsDatabase = (): DownloadsDatabase => { - const file = storage.getString(DOWNLOADS_DATABASE_KEY); - if (file) { - return JSON.parse(file) as DownloadsDatabase; - } - return { movies: {}, series: {}, other: {} }; // Initialize other media types storage - }; - - const getDownloadedItems = () => { - const db = getDownloadsDatabase(); - const allItems = [ - ...Object.values(db.movies), - ...Object.values(db.series).flatMap((series) => - Object.values(series.seasons).flatMap((season) => - Object.values(season.episodes), - ), - ), - ...Object.values(db.other), // Include other media types in results - ]; - return allItems; - }; - - const downloadedItems = getDownloadedItems(); - - const saveDownloadsDatabase = (db: DownloadsDatabase) => { - storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db)); - }; - - /** Generates a filename for a given item */ - const generateFilename = (item: BaseItemDto): string => { - let rawFilename = ""; - if (item.Type === "Movie" && item.Name) { - rawFilename = `${item.Name}`; - } else if ( - item.Type === "Episode" && - item.SeriesName && - item.ParentIndexNumber !== undefined && - item.IndexNumber !== undefined - ) { - const season = String(item.ParentIndexNumber).padStart(2, "0"); - const episode = String(item.IndexNumber).padStart(2, "0"); - rawFilename = `${item.SeriesName} S${season}E${episode} ${item.Name}`; - } else { - // Fallback to a unique name if data is missing - rawFilename = `${item.Name || "video"} ${item.Id}`; - } - // Sanitize the entire string to remove illegal characters - return rawFilename.replace(/[\\/:*?"<>|\s]/g, "_"); - }; - - /** - * Downloads the trickplay images for a given item. - * @param item - The item to download the trickplay images for. - * @returns The path to the trickplay images. - */ - const downloadTrickplayImages = async ( - item: BaseItemDto, - ): Promise => { - const trickplayInfo = getTrickplayInfo(item); - if (!api || !trickplayInfo || !item.Id) { - return undefined; - } - - const filename = generateFilename(item); - const trickplayDir = `${FileSystem.documentDirectory}${filename}_trickplay/`; - await FileSystem.makeDirectoryAsync(trickplayDir, { intermediates: true }); - let totalSize = 0; - - for (let index = 0; index < trickplayInfo.totalImageSheets; index++) { - const url = generateTrickplayUrl(item, index); - if (!url) continue; - const destination = `${trickplayDir}${index}.jpg`; - try { - await FileSystem.downloadAsync(url, destination); - const fileInfo = await FileSystem.getInfoAsync(destination); - if (fileInfo.exists) { - totalSize += fileInfo.size; - } - } catch (e) { - console.error( - `Failed to download trickplay image ${index} for item ${item.Id}`, - e, - ); - } - } - - return { path: trickplayDir, size: totalSize }; - }; - - /** - * Downloads and links external subtitles to the media source. - * @param mediaSource - The media source to download the subtitles for. - */ - const downloadAndLinkSubtitles = async ( - mediaSource: MediaSourceInfo, - item: BaseItemDto, - ) => { - const externalSubtitles = mediaSource.MediaStreams?.filter( - (stream) => - stream.Type === "Subtitle" && stream.DeliveryMethod === "External", - ); - if (externalSubtitles && api) { - await Promise.all( - externalSubtitles.map(async (subtitle) => { - const url = api.basePath + subtitle.DeliveryUrl; - const filename = generateFilename(item); - const destination = `${FileSystem.documentDirectory}${filename}_subtitle_${subtitle.Index}`; - await FileSystem.downloadAsync(url, destination); - subtitle.DeliveryUrl = destination; - }), - ); - } - }; - - /** - * Starts a download for a given process. - * @param process - The process to start the download for. - */ - const startDownload = useCallback( - async (process: JobStatus) => { - if (!process?.item.Id || !authHeader) throw new Error("No item id"); - - // Enhanced cleanup for existing tasks to prevent duplicates - try { - const allTasks = await BackGroundDownloader.checkForExistingDownloads(); - const existingTasks = allTasks?.filter((t: any) => t.id === process.id); - - if (existingTasks && existingTasks.length > 0) { - console.log( - `[START] Found ${existingTasks.length} existing task(s) for ${process.id}, cleaning up...`, - ); - - for (let i = 0; i < existingTasks.length; i++) { - const existingTask = existingTasks[i]; - console.log( - `[START] Cleaning up task ${i + 1}/${existingTasks.length} for ${process.id}`, - ); - - try { - /* - // TODO: Uncomment this block to re-enable iOS-specific cleanup - // iOS: More aggressive cleanup sequence - if (Platform.OS === "ios") { - try { - await existingTask.pause(); - await new Promise((resolve) => setTimeout(resolve, 50)); - } catch (_pauseErr) { - // Ignore pause errors - } - - await existingTask.stop(); - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Multiple complete handler calls to ensure cleanup - BackGroundDownloader.completeHandler(process.id); - await new Promise((resolve) => setTimeout(resolve, 25)); - } else { - */ - - // Simple cleanup for all platforms (currently Android only) - await existingTask.stop(); - BackGroundDownloader.completeHandler(process.id); - - /* } // End of iOS block - uncomment when re-enabling iOS functionality */ - - console.log( - `[START] Successfully cleaned up task ${i + 1} for ${process.id}`, - ); - } catch (taskError) { - console.warn( - `[START] Failed to cleanup task ${i + 1} for ${process.id}:`, - taskError, - ); - } - } - - // Cleanup delay (simplified for Android) - const cleanupDelay = 200; // Platform.OS === "ios" ? 500 : 200; - await new Promise((resolve) => setTimeout(resolve, cleanupDelay)); - console.log(`[START] Cleanup completed for ${process.id}`); - } - } catch (error) { - console.warn( - `[START] Failed to check/cleanup existing tasks for ${process.id}:`, - error, - ); - } - - updateProcess(process.id, { - speed: undefined, - status: "downloading", - progress: process.progress || 0, // Preserve existing progress for resume - }); - - BackGroundDownloader?.setConfig({ - isLogsEnabled: false, - progressInterval: 500, - headers: { - Authorization: authHeader, - }, - }); - const filename = generateFilename(process.item); - const videoFilePath = `${FileSystem.documentDirectory}${filename}.mp4`; - BackGroundDownloader?.download({ - id: process.id, - url: process.inputUrl, - destination: videoFilePath, - metadata: process, - }) - .begin(() => { - updateProcess(process.id, { - status: "downloading", - progress: process.progress || 0, - bytesDownloaded: process.bytesDownloaded || 0, - lastProgressUpdateTime: new Date(), - lastSessionBytes: process.lastSessionBytes || 0, - lastSessionUpdateTime: new Date(), - }); - }) - .progress( - throttle((data) => { - updateProcess(process.id, (currentProcess) => { - // If this is a resumed download, add the paused bytes to current session bytes - const resumedBytes = currentProcess.pausedBytes || 0; - const totalBytes = data.bytesDownloaded + resumedBytes; - - // Calculate progress based on total bytes if we have resumed bytes - let percent: number; - if (resumedBytes > 0 && data.bytesTotal > 0) { - // For resumed downloads, calculate based on estimated total size - const estimatedTotal = - currentProcess.estimatedTotalSizeBytes || - data.bytesTotal + resumedBytes; - percent = (totalBytes / estimatedTotal) * 100; - } else { - // For fresh downloads, use normal calculation - percent = (data.bytesDownloaded / data.bytesTotal) * 100; - } - - return { - speed: calculateSpeed(currentProcess, totalBytes), - status: "downloading", - progress: Math.min(percent, MAX_PROGRESS_BEFORE_COMPLETION), - bytesDownloaded: totalBytes, - lastProgressUpdateTime: new Date(), - // update session-only counters - use current session bytes only for speed calc - lastSessionBytes: data.bytesDownloaded, - lastSessionUpdateTime: new Date(), - }; - }); - }, 500), - ) - .done(async () => { - const trickPlayData = await downloadTrickplayImages(process.item); - const videoFileInfo = await FileSystem.getInfoAsync(videoFilePath); - if (!videoFileInfo.exists) { - throw new Error("Downloaded file does not exist"); - } - const videoFileSize = videoFileInfo.size; - const db = getDownloadsDatabase(); - const { item, mediaSource } = process; - // Only download external subtitles for non-transcoded streams. - if (!mediaSource.TranscodingUrl) { - await downloadAndLinkSubtitles(mediaSource, item); - } - const { introSegments, creditSegments } = await fetchAndParseSegments( - item.Id!, - api!, - ); - const downloadedItem: DownloadedItem = { - item, - mediaSource, - videoFilePath, - videoFileSize, - trickPlayData, - userData: { - audioStreamIndex: 0, - subtitleStreamIndex: 0, - }, - introSegments, - creditSegments, - }; - - if (item.Type === "Movie" && item.Id) { - db.movies[item.Id] = downloadedItem; - } else if ( - item.Type === "Episode" && - item.SeriesId && - item.ParentIndexNumber !== undefined && - item.ParentIndexNumber !== null && - item.IndexNumber !== undefined && - item.IndexNumber !== null - ) { - if (!db.series[item.SeriesId]) { - const seriesInfo: Partial = { - Id: item.SeriesId, - Name: item.SeriesName, - Type: "Series", - }; - db.series[item.SeriesId] = { - seriesInfo: seriesInfo as BaseItemDto, - seasons: {}, - }; - } - - const seasonNumber = item.ParentIndexNumber; - if (!db.series[item.SeriesId].seasons[seasonNumber]) { - db.series[item.SeriesId].seasons[seasonNumber] = { - episodes: {}, - }; - } - - const episodeNumber = item.IndexNumber; - db.series[item.SeriesId].seasons[seasonNumber].episodes[ - episodeNumber - ] = downloadedItem; - } else if (item.Id) { - // Handle other media types - db.other[item.Id] = downloadedItem; - } - await saveDownloadsDatabase(db); - - // Send native notification for successful download - const successNotification = getNotificationContent( - process.item, - true, - ); - await sendDownloadNotification( - successNotification.title, - successNotification.body, - { - itemId: process.item.Id, - itemName: process.item.Name, - type: "download_completed", - }, - ); - - toast.success( - t("home.downloads.toasts.download_completed_for_item", { - item: process.item.Name, - }), - ); - removeProcess(process.id); - }) - .error(async (error: any) => { - console.error("Download error:", error); - - // Send native notification for failed download - const failureNotification = getNotificationContent( - process.item, - false, - ); - await sendDownloadNotification( - failureNotification.title, - failureNotification.body, - { - itemId: process.item.Id, - itemName: process.item.Name, - type: "download_failed", - error: error?.message || "Unknown error", - }, - ); - - toast.error( - t("home.downloads.toasts.download_failed_for_item", { - item: process.item.Name, - }), - ); - removeProcess(process.id); - }); - }, - [authHeader, sendDownloadNotification, getNotificationContent], - ); - - const manageDownloadQueue = useCallback(() => { - const activeDownloads = processes.filter( - (p) => p.status === "downloading", - ).length; - const concurrentLimit = settings?.remuxConcurrentLimit || 1; - if (activeDownloads < concurrentLimit) { - const queuedDownload = processes.find((p) => p.status === "queued"); - if (queuedDownload) { - // Reserve the slot immediately to avoid race where startDownload's - // asynchronous begin callback hasn't executed yet and multiple - // downloads are started, bypassing the concurrent limit. - updateProcess(queuedDownload.id, { status: "downloading" }); - startDownload(queuedDownload).catch((error) => { - console.error("Failed to start download:", error); - updateProcess(queuedDownload.id, { status: "error" }); - toast.error(t("home.downloads.toasts.failed_to_start_download"), { - description: error.message || "Unknown error", - }); - }); - } - } - }, [processes, settings?.remuxConcurrentLimit, startDownload]); - const removeProcess = useCallback( - async (id: string) => { - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - const task = tasks?.find((t: any) => t.id === id); - if (task) { - // On iOS, suspended tasks need to be cancelled properly - if (Platform.OS === "ios") { - const state = task.state || task.state?.(); - if ( - state === "PAUSED" || - state === "paused" || - state === "SUSPENDED" || - state === "suspended" - ) { - // For suspended tasks, we need to resume first, then stop - try { - await task.resume(); - // Small delay to allow resume to take effect - await new Promise((resolve) => setTimeout(resolve, 100)); - } catch (_resumeError) { - // Resume might fail, continue with stop - } - } - } - - try { - task.stop(); - } catch (_err) { - // ignore stop errors - } - try { - BackGroundDownloader.completeHandler(id); - } catch (_err) { - // ignore - } - } + (id: string) => { setProcesses((prev) => prev.filter((process) => process.id !== id)); - manageDownloadQueue(); + + // Find and remove from task map + taskMapRef.current.forEach((processId, taskId) => { + if (processId === id) { + taskMapRef.current.delete(taskId); + } + }); }, - [setProcesses, manageDownloadQueue], + [setProcesses], ); - useEffect(() => { - manageDownloadQueue(); - }, [processes, manageDownloadQueue]); + // Set up download event handlers + useDownloadEventHandlers({ + taskMapRef, + processes, + updateProcess, + removeProcess, + onSuccess: successHapticFeedback, + onDataChange: triggerRefresh, + api: api || undefined, + }); - /** - * Cleans the cache directory. - */ - const cleanCacheDirectory = async (): Promise => { - try { - await FileSystem.deleteAsync(APP_CACHE_DOWNLOAD_DIRECTORY, { - idempotent: true, - }); - await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, { - intermediates: true, - }); - } catch (_error) { - toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory")); - } - }; - - const startBackgroundDownload = useCallback( - async ( - url: string, - item: BaseItemDto, - mediaSource: MediaSourceInfo, - maxBitrate: Bitrate, - ) => { - if (!api || !item.Id || !authHeader) - throw new Error("startBackgroundDownload ~ Missing required params"); - try { - const deviceId = getOrSetDeviceId(); - await saveSeriesPrimaryImage(item); - const itemImage = getItemImage({ - item, - api, - variant: "Primary", - quality: 90, - width: 500, - }); - await saveImage(item.Id, itemImage?.uri); - const job: JobStatus = { - id: item.Id!, - deviceId: deviceId, - maxBitrate, - inputUrl: url, - item: item, - itemId: item.Id!, - mediaSource, - progress: 0, - status: "queued", - timestamp: new Date(), - }; - setProcesses((prev) => { - // Remove any existing processes for this item to prevent duplicates - const filtered = prev.filter((p) => p.id !== item.Id); - return [...filtered, job]; - }); - toast.success( - t("home.downloads.toasts.download_started_for_item", { - item: item.Name, - }), - { - action: { - label: t("home.downloads.toasts.go_to_downloads"), - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }, - ); - } catch (error) { - writeToLog("ERROR", "Error in startBackgroundDownload", error); - } - }, - [authHeader, startDownload], - ); - - const deleteFile = async (id: string, type: BaseItemDto["Type"]) => { - const db = getDownloadsDatabase(); - let downloadedItem: DownloadedItem | undefined; - - if (type === "Movie" && Object.entries(db.movies).length !== 0) { - downloadedItem = db.movies[id]; - if (downloadedItem) { - delete db.movies[id]; - } - } else if (type === "Episode" && Object.entries(db.series).length !== 0) { - const cleanUpEmptyParents = ( - series: any, - seasonNumber: string, - seriesId: string, - ) => { - if (!Object.keys(series.seasons[seasonNumber].episodes).length) { - delete series.seasons[seasonNumber]; - } - if (!Object.keys(series.seasons).length) { - delete db.series[seriesId]; - } - }; - - for (const [seriesId, series] of Object.entries(db.series)) { - for (const [seasonNumber, season] of Object.entries(series.seasons)) { - for (const [episodeNumber, episode] of Object.entries( - season.episodes, - )) { - if (episode.item.Id === id) { - downloadedItem = episode; - delete season.episodes[Number(episodeNumber)]; - cleanUpEmptyParents(series, seasonNumber, seriesId); - break; - } - } - if (downloadedItem) break; - } - if (downloadedItem) break; - } - } else { - // Handle other media types - downloadedItem = db.other[id]; - if (downloadedItem) { - delete db.other[id]; - } - } - - if (downloadedItem?.videoFilePath) { - await FileSystem.deleteAsync(downloadedItem.videoFilePath, { - idempotent: true, - }); - } - - if (downloadedItem?.mediaSource?.MediaStreams) { - for (const stream of downloadedItem.mediaSource.MediaStreams) { - if ( - stream.Type === "Subtitle" && - stream.DeliveryMethod === "External" - ) { - await FileSystem.deleteAsync(stream.DeliveryUrl!, { - idempotent: true, - }); - } - } - } - - if (downloadedItem?.trickPlayData?.path) { - await FileSystem.deleteAsync(downloadedItem.trickPlayData.path, { - idempotent: true, - }); - } - - await saveDownloadsDatabase(db); - successHapticFeedback(); - }; - - const deleteItems = async (items: BaseItemDto[]) => { - for (const item of items) { - if (item.Id) { - await deleteFile(item.Id, item.Type); - } - } - }; - - /** Deletes all files */ - const deleteAllFiles = async (): Promise => { - await deleteFileByType("Movie"); - await deleteFileByType("Episode"); - toast.success( - t( - "home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully", - ), - ); - }; - - /** Deletes all files of a given type. */ - const deleteFileByType = async (type: BaseItemDto["Type"]) => { - const itemsToDelete = downloadedItems?.filter( - (file) => file.item.Type === type, - ); - if (itemsToDelete) await deleteItems(itemsToDelete.map((i) => i.item)); - }; - - /** Returns the size of a downloaded item. */ - const getDownloadedItemSize = (itemId: string): number => { - const downloadedItem = getDownloadedItemById(itemId); - if (!downloadedItem) return 0; - - const trickplaySize = downloadedItem.trickPlayData?.size || 0; - return downloadedItem.videoFileSize + trickplaySize; - }; - - /** Updates a downloaded item. */ - const updateDownloadedItem = ( - itemId: string, - updatedItem: DownloadedItem, - ) => { - const db = getDownloadsDatabase(); - if (db.movies[itemId]) { - db.movies[itemId] = updatedItem; - } else if (db.other[itemId]) { - db.other[itemId] = updatedItem; - } else { - for (const series of Object.values(db.series)) { - for (const season of Object.values(series.seasons)) { - for (const episode of Object.values(season.episodes)) { - if (episode.item.Id === itemId) { - season.episodes[episode.item.IndexNumber as number] = updatedItem; - } - } - } - } - } - saveDownloadsDatabase(db); - }; - - /** - * Returns the size of the app and the remaining space on the device. - * @returns The size of the app and the remaining space on the device. - */ - const appSizeUsage = async () => { - const [total, remaining] = await Promise.all([ - FileSystem.getTotalDiskCapacityAsync(), - FileSystem.getFreeDiskStorageAsync(), - ]); - - let appSize = 0; - const downloadedFiles = await FileSystem.readDirectoryAsync( - `${FileSystem.documentDirectory!}`, - ); - for (const file of downloadedFiles) { - const fileInfo = await FileSystem.getInfoAsync( - `${FileSystem.documentDirectory!}${file}`, - ); - if (fileInfo.exists) { - appSize += fileInfo.size; - } - } - return { total, remaining, appSize: appSize }; - }; - - const pauseDownload = useCallback( - async (id: string) => { - const process = processes.find((p) => p.id === id); - if (!process) throw new Error("No active download"); - - // TODO: iOS pause functionality temporarily disabled due to background task issues - // Remove this check to re-enable iOS pause functionality in the future - if (Platform.OS === "ios") { - console.warn( - `[PAUSE] Pause functionality temporarily disabled on iOS for ${id}`, - ); - throw new Error("Pause functionality is currently disabled on iOS"); - } - - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - const task = tasks?.find((t: any) => t.id === id); - if (!task) throw new Error("No task found"); - - // Get current progress before stopping - const currentProgress = process.progress; - const currentBytes = process.bytesDownloaded || task.bytesDownloaded || 0; - - console.log( - `[PAUSE] Starting pause for ${id}. Current bytes: ${currentBytes}, Progress: ${currentProgress}%`, - ); - - try { - /* - // TODO: Uncomment this block to re-enable iOS pause functionality - // iOS-specific aggressive cleanup approach based on GitHub issue #26 - if (Platform.OS === "ios") { - // Get ALL tasks for this ID - there might be multiple zombie tasks - const allTasks = - await BackGroundDownloader.checkForExistingDownloads(); - const tasksForId = allTasks?.filter((t: any) => t.id === id) || []; - - console.log(`[PAUSE] Found ${tasksForId.length} task(s) for ${id}`); - - // Stop ALL tasks for this ID to prevent zombie processes - for (let i = 0; i < tasksForId.length; i++) { - const taskToStop = tasksForId[i]; - console.log( - `[PAUSE] Stopping task ${i + 1}/${tasksForId.length} for ${id}`, - ); - - try { - // iOS: pause → stop sequence with delays (based on issue research) - await taskToStop.pause(); - await new Promise((resolve) => setTimeout(resolve, 100)); - - await taskToStop.stop(); - await new Promise((resolve) => setTimeout(resolve, 100)); - - console.log( - `[PAUSE] Successfully stopped task ${i + 1} for ${id}`, - ); - } catch (taskError) { - console.warn( - `[PAUSE] Failed to stop task ${i + 1} for ${id}:`, - taskError, - ); - } - } - - // Extra cleanup delay for iOS NSURLSession to fully stop - await new Promise((resolve) => setTimeout(resolve, 500)); - } else { - */ - - // Android: simpler approach (currently the only active platform) - await task.stop(); - - /* } // End of iOS block - uncomment when re-enabling iOS functionality */ - - // Clean up the native task handler - try { - BackGroundDownloader.completeHandler(id); - } catch (_err) { - console.warn(`[PAUSE] Handler cleanup warning for ${id}:`, _err); - } - - // Update process state to paused - updateProcess(id, { - status: "paused", - progress: currentProgress, - bytesDownloaded: currentBytes, - pausedAt: new Date(), - pausedProgress: currentProgress, - pausedBytes: currentBytes, - lastSessionBytes: process.lastSessionBytes ?? currentBytes, - lastSessionUpdateTime: process.lastSessionUpdateTime ?? new Date(), - }); - - console.log(`Download paused successfully: ${id}`); - } catch (error) { - console.error("Error pausing task:", error); - throw error; - } - }, - [processes, updateProcess], - ); - - const resumeDownload = useCallback( - async (id: string) => { - const process = processes.find((p) => p.id === id); - if (!process) throw new Error("No active download"); - - // TODO: iOS resume functionality temporarily disabled due to background task issues - // Remove this check to re-enable iOS resume functionality in the future - if (Platform.OS === "ios") { - console.warn( - `[RESUME] Resume functionality temporarily disabled on iOS for ${id}`, - ); - throw new Error("Resume functionality is currently disabled on iOS"); - } - - console.log( - `[RESUME] Attempting to resume ${id}. Paused bytes: ${process.pausedBytes}, Progress: ${process.pausedProgress}%`, - ); - - /* - // TODO: Uncomment this block to re-enable iOS resume functionality - // Enhanced cleanup for iOS based on GitHub issue research - if (Platform.OS === "ios") { - try { - // Clean up any lingering zombie tasks first (critical for iOS) - const allTasks = - await BackGroundDownloader.checkForExistingDownloads(); - const existingTasks = allTasks?.filter((t: any) => t.id === id) || []; - - if (existingTasks.length > 0) { - console.log( - `[RESUME] Found ${existingTasks.length} lingering task(s), cleaning up...`, - ); - - for (const task of existingTasks) { - try { - await task.stop(); - BackGroundDownloader.completeHandler(id); - } catch (cleanupError) { - console.warn(`[RESUME] Cleanup error:`, cleanupError); - } - } - - // Wait for iOS cleanup to complete - await new Promise((resolve) => setTimeout(resolve, 500)); - } - } catch (error) { - console.warn(`[RESUME] Pre-resume cleanup failed:`, error); - } - } - */ - - // Simple approach: always restart the download from where we left off - // This works consistently across all platforms (currently Android only) - if ( - process.pausedProgress !== undefined && - process.pausedBytes !== undefined - ) { - // We have saved pause state - restore it and restart - updateProcess(id, { - progress: process.pausedProgress, - bytesDownloaded: process.pausedBytes, - status: "downloading", - // Reset session counters for proper speed calculation - lastSessionBytes: process.pausedBytes, - lastSessionUpdateTime: new Date(), - }); - - // Small delay to ensure any cleanup in startDownload completes - await new Promise((resolve) => setTimeout(resolve, 100)); - - const updatedProcess = processes.find((p) => p.id === id); - await startDownload(updatedProcess || process); - - console.log(`Download resumed successfully: ${id}`); - } else { - // No pause state - start from beginning - await startDownload(process); - } - }, - [processes, updateProcess, startDownload], - ); + // Get download operation functions + const { + startBackgroundDownload, + cancelDownload, + deleteFile, + deleteItems, + deleteAllFiles, + deleteFileByType, + appSizeUsage, + } = useDownloadOperations({ + taskMapRef, + processes, + setProcesses, + removeProcess, + api, + authHeader, + onDataChange: triggerRefresh, + }); return { processes, startBackgroundDownload, - getDownloadedItems, + downloadedItems, // Reactive value that auto-updates + getDownloadedItems: getAllDownloadedItems, // Keep for backward compatibility getDownloadsDatabase, deleteAllFiles, deleteFile, deleteItems, - removeProcess, - startDownload, - pauseDownload, - resumeDownload, deleteFileByType, + removeProcess, + cancelDownload, getDownloadedItemSize, getDownloadedItemById, - APP_CACHE_DOWNLOAD_DIRECTORY, - cleanCacheDirectory, - updateDownloadedItem, + triggerRefresh, + APP_CACHE_DOWNLOAD_DIRECTORY: APP_CACHE_DOWNLOAD_DIRECTORY.uri, appSizeUsage, - dumpDownloadDiagnostics: async (id?: string) => { - // Collect JS-side processes and native task info (best-effort) - const tasks = BackGroundDownloader - ? await BackGroundDownloader.checkForExistingDownloads() - : []; - const extra: any = { - processes, - nativeTasks: tasks || [], - }; - if (id) { - const p = processes.find((x) => x.id === id); - extra.focusedProcess = p || null; - } - return dumpDownloadDiagnostics(extra); - }, + // Deprecated/not implemented in simple version + startDownload: async () => {}, + cleanCacheDirectory: async () => {}, + updateDownloadedItem: () => {}, + dumpDownloadDiagnostics: async () => "", }; } @@ -1251,26 +145,27 @@ export function useDownload() { const context = useContext(DownloadContext); if (Platform.isTV) { - // Since tv doesn't do downloads, just return no-op functions for everything return { processes: [], startBackgroundDownload: async () => {}, + downloadedItems: [], getDownloadedItems: () => [], - getDownloadsDatabase: () => ({}), + getDownloadsDatabase: () => ({ movies: {}, series: {}, other: {} }), deleteAllFiles: async () => {}, deleteFile: async () => {}, deleteItems: async () => {}, - removeProcess: () => {}, - startDownload: async () => {}, - pauseDownload: async () => {}, - resumeDownload: async () => {}, deleteFileByType: async () => {}, + removeProcess: () => {}, + cancelDownload: async () => {}, + triggerRefresh: () => {}, + startDownload: async () => {}, getDownloadedItemSize: () => 0, getDownloadedItemById: () => undefined, APP_CACHE_DOWNLOAD_DIRECTORY: "", cleanCacheDirectory: async () => {}, updateDownloadedItem: () => {}, appSizeUsage: async () => ({ total: 0, remaining: 0, appSize: 0 }), + dumpDownloadDiagnostics: async () => "", }; } @@ -1283,6 +178,7 @@ export function useDownload() { export function DownloadProvider({ children }: { children: React.ReactNode }) { const downloadUtils = useDownloadProvider(); + return ( {children} diff --git a/providers/Downloads/MIGRATION.md b/providers/Downloads/MIGRATION.md new file mode 100644 index 00000000..f75c3f5e --- /dev/null +++ b/providers/Downloads/MIGRATION.md @@ -0,0 +1,188 @@ +# Download Provider Migration Guide + +## Overview + +The DownloadProvider has been completely rewritten to use the new native `BackgroundDownloader` module instead of the third-party `@kesha-antonov/react-native-background-downloader` library. + +## What Changed + +### New Implementation + +- **Native Module**: Uses our custom `BackgroundDownloader` Expo module built with NSURLSession +- **Simplified**: Focuses only on downloading video files +- **Background Support**: True iOS background downloads with system integration +- **Event-Driven**: Uses native event emitters for progress, completion, and errors + +### Removed Features (For Now) + +The following features from the old implementation have been temporarily removed to simplify the initial version: + +- ✗ Trickplay image downloads +- ✗ Subtitle downloads +- ✗ Series primary image caching +- ✗ Intro/credit segment fetching +- ✗ Download queue management with concurrent limits +- ✗ Pause/Resume functionality +- ✗ Speed calculation and ETA +- ✗ Cache directory management + +### Maintained Features + +- ✓ Download video files with progress tracking +- ✓ Database persistence (same structure) +- ✓ Movies and Episodes support +- ✓ Download notifications +- ✓ File deletion and management +- ✓ Downloaded items listing +- ✓ Same context API + +## API Compatibility + +The public API remains mostly the same to avoid breaking existing code: + +### Working Methods + +```typescript +const { + // Core functionality + startBackgroundDownload, + cancelDownload, + + // Database operations + getDownloadedItems, + getDownloadsDatabase, + getDownloadedItemById, + getDownloadedItemSize, + + // File management + deleteFile, + deleteItems, + deleteAllFiles, + + // State + processes, + APP_CACHE_DOWNLOAD_DIRECTORY, + appSizeUsage, +} = useDownload(); +``` + +### Deprecated (No-op) Methods + +These methods exist but do nothing in the new version: + +- `startDownload()` - Use `startBackgroundDownload()` instead +- `pauseDownload()` - Not supported yet +- `resumeDownload()` - Not supported yet +- `deleteFileByType()` - Not needed (only video files) +- `cleanCacheDirectory()` - Not needed +- `updateDownloadedItem()` - Not needed +- `dumpDownloadDiagnostics()` - Not needed + +## Migration Steps + +### For Developers + +1. **No code changes needed** if you're using `startBackgroundDownload()` and basic file management +2. **Remove calls** to deprecated methods (they won't break but do nothing) +3. **Test downloads** to ensure they work in your workflows + +### For Users + +- **No action required** - the new system uses the same database format +- **Existing downloads** will still be accessible +- **New downloads** will use the improved background system + +## Future Enhancements + +Planned features to add back: + +1. **Pause/Resume**: Using NSURLSession's built-in pause/resume +2. **Queue Management**: Better control over concurrent downloads +3. **Trickplay**: Re-add trickplay image downloading +4. **Subtitles**: Download and link subtitle files +5. **Progress Persistence**: Resume downloads after app restart +6. **Cellular Control**: Respect cellular data settings +7. **Speed/ETA**: Better download metrics + +## Database Structure + +The database structure remains unchanged: + +```typescript +interface DownloadsDatabase { + movies: Record; + series: Record; + other: Record; +} + +interface DownloadedItem { + item: BaseItemDto; + mediaSource: MediaSourceInfo; + videoFilePath: string; + videoFileSize: number; + videoFileName?: string; + trickPlayData?: TrickPlayData; + introSegments?: MediaTimeSegment[]; + creditSegments?: MediaTimeSegment[]; + userData: UserData; +} +``` + +## Known Differences + +1. **Progress Updates**: More frequent and accurate with native module +2. **Background Handling**: Better iOS background download support +3. **Error Messages**: Different error format from native module +4. **File Paths**: Uses `Paths.document` instead of cache directory +5. **No Queue**: Downloads start immediately (no queuing system yet) + +## Troubleshooting + +### Downloads not starting + +- Check that the iOS app has been rebuilt with the new native module +- Verify network permissions +- Check console logs for errors + +### Progress not updating + +- Ensure event listeners are properly registered +- Check that the task ID mapping is correct +- Verify the download is still active + +### Files not found + +- Old downloads might be in a different location +- Re-download content if files are missing +- Check file permissions + +## Old Implementation + +The old implementation has been preserved at: +- `providers/DownloadProvider.deprecated.tsx` + +You can reference it if needed, but it should not be used in production. + +## Testing + +After migration, test these scenarios: + +- [ ] Download a movie +- [ ] Download an episode +- [ ] Download multiple items +- [ ] Cancel a download +- [ ] Delete a downloaded item +- [ ] View downloaded items list +- [ ] Background app during download +- [ ] Force quit and restart app +- [ ] Verify notifications appear +- [ ] Check file sizes are correct + +## Questions? + +If you encounter issues with the migration, please: +1. Check the console logs +2. Verify the native module is installed +3. Review the old implementation for reference +4. Open an issue with details + diff --git a/providers/Downloads/README.md b/providers/Downloads/README.md new file mode 100644 index 00000000..0cbd7bcf --- /dev/null +++ b/providers/Downloads/README.md @@ -0,0 +1,149 @@ +# Downloads Module + +This module handles all download functionality for the Streamyfin app, including video downloads, subtitles, trickplay images, and cover images. + +## Architecture + +The downloads module is structured with a clean separation of concerns: + +### Core Files + +- **`database.ts`** - Pure functions for MMKV database operations +- **`fileOperations.ts`** - Pure functions for file system operations +- **`utils.ts`** - Pure utility functions (filename generation, URI conversion) +- **`additionalDownloads.ts`** - Pure functions for downloading additional assets +- **`notifications.ts`** - Pure functions for notification handling +- **`types.ts`** - TypeScript type definitions + +### Hooks + +- **`useDownloadOperations.ts`** - Hook providing download operations (start, cancel, delete) +- **`useDownloadEventHandlers.ts`** - Hook setting up native download event listeners + +### Main Provider + +- **`DownloadProvider.tsx`** - React context provider that orchestrates all download functionality + +## Features + +### Video Downloads +- Background download support using native module +- Progress tracking and reporting +- Pause/resume capability (future enhancement) +- Download queue management + +### Additional Assets (Automatic) +When a video download completes, the following are automatically downloaded: + +1. **Trickplay Images** - Preview thumbnail sheets for video scrubbing +2. **Subtitles** - External subtitle files (for non-transcoded content) +3. **Cover Images** - Primary item images and series images +4. **Segments** - Intro and credit skip timestamps + +### File Management +- Automatic cleanup of all associated files (video, subtitles, trickplay) +- Size calculation including all assets +- Batch delete operations + +## Implementation Details + +### Pure Functions +All core logic is implemented as pure functions that: +- Take explicit parameters +- Return explicit values +- Have no side effects +- Are easily testable + +### Imperative Design +The module uses imperative function calls rather than reactive patterns: +- Direct function invocation +- Explicit error handling +- Clear control flow +- Minimal side effects + +### Storage +- **MMKV** - Used for persistent database storage +- **expo-file-system** - Used for file operations +- **Native module** - Used for background downloads + +## Usage + +```typescript +import { useDownload } from '@/providers/DownloadProvider'; + +function MyComponent() { + const { + startBackgroundDownload, + cancelDownload, + deleteFile, + getDownloadedItems, + processes, + } = useDownload(); + + // Start a download + await startBackgroundDownload(url, item, mediaSource, bitrate); + + // Cancel a download + await cancelDownload(itemId); + + // Delete a download + await deleteFile(itemId); + + // Get all downloads + const items = getDownloadedItems(); +} +``` + +## Event Flow + +1. **Start Download** + - Pre-download cover images + - Start video download via native module + - Track progress via event listeners + +2. **Download Progress** + - Native module emits progress events + - React state updated with progress percentage + - UI reflects current download state + +3. **Download Complete** + - Video file saved to disk + - Additional assets downloaded in parallel: + - Trickplay images + - Subtitles (if applicable) + - Segments data + - Item saved to database + - Notification sent + - Process removed from queue + +4. **Delete** + - Item removed from database + - All associated files deleted: + - Video file + - Subtitle files + - Trickplay directory + +## File Structure + +``` +providers/Downloads/ +├── additionalDownloads.ts # Trickplay, subtitles, cover images +├── database.ts # MMKV operations +├── fileOperations.ts # File system operations +├── notifications.ts # Notification helpers +├── types.ts # TypeScript types +├── utils.ts # Utility functions +├── index.ts # Module exports +├── hooks/ +│ ├── useDownloadEventHandlers.ts +│ └── useDownloadOperations.ts +└── README.md # This file +``` + +## Future Enhancements + +- Background download scheduling +- Network condition awareness +- Download priority management +- Automatic cleanup of old downloads +- Series season download management diff --git a/providers/Downloads/additionalDownloads.ts b/providers/Downloads/additionalDownloads.ts new file mode 100644 index 00000000..af6a5894 --- /dev/null +++ b/providers/Downloads/additionalDownloads.ts @@ -0,0 +1,243 @@ +import type { Api } from "@jellyfin/sdk"; +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { Directory, File, Paths } from "expo-file-system"; +import { getItemImage } from "@/utils/getItemImage"; +import { fetchAndParseSegments } from "@/utils/segments"; +import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay"; +import type { MediaTimeSegment, TrickPlayData } from "./types"; +import { generateFilename } from "./utils"; + +/** + * Downloads trickplay images for an item + * @returns TrickPlayData with path and size, or undefined if not available + */ +export async function downloadTrickplayImages( + item: BaseItemDto, +): Promise { + const trickplayInfo = getTrickplayInfo(item); + if (!trickplayInfo || !item.Id) { + return undefined; + } + + const filename = generateFilename(item); + const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`); + + // Create directory if it doesn't exist + if (!trickplayDir.exists) { + trickplayDir.create({ intermediates: true }); + } + + let totalSize = 0; + const downloadPromises: Promise[] = []; + + for (let index = 0; index < trickplayInfo.totalImageSheets; index++) { + const url = generateTrickplayUrl(item, index); + if (!url) continue; + + const destination = new File(trickplayDir, `${index}.jpg`); + + // Skip if already exists + if (destination.exists) { + totalSize += destination.size; + continue; + } + + downloadPromises.push( + File.downloadFileAsync(url, destination) + .then(() => { + totalSize += destination.size; + }) + .catch((error) => { + console.error( + `[TRICKPLAY] Failed to download sheet ${index}:`, + error, + ); + }), + ); + } + + await Promise.all(downloadPromises); + + return { + path: trickplayDir.uri, + size: totalSize, + }; +} + +/** + * Downloads external subtitle files and updates their delivery URLs to local paths + * @returns Updated media source with local subtitle paths + */ +export async function downloadSubtitles( + mediaSource: MediaSourceInfo, + item: BaseItemDto, + apiBasePath: string, +): Promise { + const externalSubtitles = mediaSource.MediaStreams?.filter( + (stream) => + stream.Type === "Subtitle" && stream.DeliveryMethod === "External", + ); + + if (!externalSubtitles || externalSubtitles.length === 0) { + return mediaSource; + } + + const filename = generateFilename(item); + const downloadPromises = externalSubtitles.map(async (subtitle) => { + if (!subtitle.DeliveryUrl) return; + + const url = apiBasePath + subtitle.DeliveryUrl; + const extension = subtitle.Codec || "srt"; + const destination = new File( + Paths.document, + `${filename}_subtitle_${subtitle.Index}.${extension}`, + ); + + // Skip if already exists + if (destination.exists) { + subtitle.DeliveryUrl = destination.uri; + return; + } + + try { + await File.downloadFileAsync(url, destination); + subtitle.DeliveryUrl = destination.uri; + } catch (error) { + console.error( + `[SUBTITLES] Failed to download subtitle ${subtitle.Index}:`, + error, + ); + } + }); + + await Promise.all(downloadPromises); + + return mediaSource; +} + +/** + * Downloads and saves the cover image for an item + * @returns Path to the saved image, or undefined if failed + */ +export async function downloadCoverImage( + item: BaseItemDto, + api: Api, + saveImageFn: (itemId: string, url?: string) => Promise, +): Promise { + if (!item.Id) { + console.log(`[COVER] No item ID for cover image`); + return undefined; + } + + try { + const itemImage = getItemImage({ + item, + api, + variant: "Primary", + quality: 90, + width: 500, + }); + + if (!itemImage?.uri) { + return undefined; + } + + await saveImageFn(item.Id, itemImage.uri); + + return itemImage.uri; + } catch (error) { + console.error(`[COVER] Failed to download cover image:`, error); + return undefined; + } +} + +/** + * Downloads and saves the series primary image for an episode + * @returns Path to the saved image, or undefined if failed + */ +export async function downloadSeriesImage( + item: BaseItemDto, + saveSeriesImageFn: (item: BaseItemDto) => Promise, +): Promise { + if (item.Type !== "Episode" || !item.SeriesId) { + return; + } + + try { + await saveSeriesImageFn(item); + } catch (error) { + console.error(`[COVER] Failed to download series image:`, error); + } +} + +/** + * Fetches intro and credit segments for an item + */ +export async function fetchSegments( + itemId: string, + api: Api, +): Promise<{ + introSegments?: MediaTimeSegment[]; + creditSegments?: MediaTimeSegment[]; +}> { + try { + const segments = await fetchAndParseSegments(itemId, api); + return segments; + } catch (error) { + console.error(`[SEGMENTS] Failed to fetch segments:`, error); + return {}; + } +} + +/** + * Orchestrates all additional downloads for a completed item + * Called after main video download completes + */ +export async function downloadAdditionalAssets(params: { + item: BaseItemDto; + mediaSource: MediaSourceInfo; + api: Api; + saveImageFn: (itemId: string, url?: string) => Promise; + saveSeriesImageFn: (item: BaseItemDto) => Promise; +}): Promise<{ + trickPlayData?: TrickPlayData; + updatedMediaSource: MediaSourceInfo; + introSegments?: MediaTimeSegment[]; + creditSegments?: MediaTimeSegment[]; +}> { + const { item, mediaSource, api, saveImageFn, saveSeriesImageFn } = params; + + // Run all downloads in parallel for speed + const [ + trickPlayData, + updatedMediaSource, + segments, + // Cover images (fire and forget, errors are logged) + ] = await Promise.all([ + downloadTrickplayImages(item), + // Only download subtitles for non-transcoded streams + mediaSource.TranscodingUrl + ? Promise.resolve(mediaSource) + : downloadSubtitles(mediaSource, item, api.basePath || ""), + item.Id ? fetchSegments(item.Id, api) : Promise.resolve({}), + // Cover image downloads (run but don't wait for results) + downloadCoverImage(item, api, saveImageFn).catch((err) => { + console.error("[COVER] Error downloading cover:", err); + return undefined; + }), + downloadSeriesImage(item, saveSeriesImageFn).catch((err) => { + console.error("[COVER] Error downloading series image:", err); + return undefined; + }), + ]); + + return { + trickPlayData, + updatedMediaSource, + introSegments: segments.introSegments, + creditSegments: segments.creditSegments, + }; +} diff --git a/providers/Downloads/database.ts b/providers/Downloads/database.ts new file mode 100644 index 00000000..27521edb --- /dev/null +++ b/providers/Downloads/database.ts @@ -0,0 +1,189 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { storage } from "@/utils/mmkv"; +import type { DownloadedItem, DownloadsDatabase } from "./types"; + +const DOWNLOADS_DATABASE_KEY = "downloads.v2.json"; + +/** + * Get the downloads database from storage + */ +export function getDownloadsDatabase(): DownloadsDatabase { + const file = storage.getString(DOWNLOADS_DATABASE_KEY); + if (file) { + return JSON.parse(file) as DownloadsDatabase; + } + return { movies: {}, series: {}, other: {} }; +} + +/** + * Save the downloads database to storage + */ +export function saveDownloadsDatabase(db: DownloadsDatabase): void { + storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db)); +} + +/** + * Get all downloaded items as a flat array + */ +export function getAllDownloadedItems(): DownloadedItem[] { + const db = getDownloadsDatabase(); + const items: DownloadedItem[] = []; + + for (const movie of Object.values(db.movies)) { + items.push(movie); + } + + for (const series of Object.values(db.series)) { + for (const season of Object.values(series.seasons)) { + for (const episode of Object.values(season.episodes)) { + items.push(episode); + } + } + } + + if (db.other) { + for (const item of Object.values(db.other)) { + items.push(item); + } + } + + return items; +} + +/** + * Get a downloaded item by its ID + */ +export function getDownloadedItemById(id: string): DownloadedItem | undefined { + const db = getDownloadsDatabase(); + + if (db.movies[id]) { + return db.movies[id]; + } + + for (const series of Object.values(db.series)) { + for (const season of Object.values(series.seasons)) { + for (const episode of Object.values(season.episodes)) { + if (episode.item.Id === id) { + return episode; + } + } + } + } + + if (db.other?.[id]) { + return db.other[id]; + } + + return undefined; +} + +/** + * Add a downloaded item to the database + */ +export function addDownloadedItem(item: DownloadedItem): void { + const db = getDownloadsDatabase(); + const baseItem = item.item; + + if (baseItem.Type === "Movie" && baseItem.Id) { + db.movies[baseItem.Id] = item; + } else if ( + baseItem.Type === "Episode" && + baseItem.SeriesId && + baseItem.ParentIndexNumber !== undefined && + baseItem.ParentIndexNumber !== null && + baseItem.IndexNumber !== undefined && + baseItem.IndexNumber !== null + ) { + // Ensure series exists + if (!db.series[baseItem.SeriesId]) { + const seriesInfo: Partial = { + Id: baseItem.SeriesId, + Name: baseItem.SeriesName, + Type: "Series", + }; + db.series[baseItem.SeriesId] = { + seriesInfo: seriesInfo as BaseItemDto, + seasons: {}, + }; + } + + // Ensure season exists + const seasonNumber = baseItem.ParentIndexNumber; + if (!db.series[baseItem.SeriesId].seasons[seasonNumber]) { + db.series[baseItem.SeriesId].seasons[seasonNumber] = { + episodes: {}, + }; + } + + // Add episode + const episodeNumber = baseItem.IndexNumber; + db.series[baseItem.SeriesId].seasons[seasonNumber].episodes[episodeNumber] = + item; + } else if (baseItem.Id) { + if (!db.other) db.other = {}; + db.other[baseItem.Id] = item; + } + + saveDownloadsDatabase(db); +} + +/** + * Remove a downloaded item from the database + * Returns the removed item if found, undefined otherwise + */ +export function removeDownloadedItem(id: string): DownloadedItem | undefined { + const db = getDownloadsDatabase(); + let itemToDelete: DownloadedItem | undefined; + + // Check movies + if (db.movies[id]) { + itemToDelete = db.movies[id]; + delete db.movies[id]; + } else { + // Check series episodes + for (const seriesId in db.series) { + const series = db.series[seriesId]; + for (const seasonNum in series.seasons) { + const season = series.seasons[seasonNum]; + for (const episodeNum in season.episodes) { + const episode = season.episodes[episodeNum]; + if (episode.item.Id === id) { + itemToDelete = episode; + delete season.episodes[episodeNum]; + + // Clean up empty season + if (Object.keys(season.episodes).length === 0) { + delete series.seasons[seasonNum]; + } + + // Clean up empty series + if (Object.keys(series.seasons).length === 0) { + delete db.series[seriesId]; + } + + break; + } + } + } + } + + // Check other items + if (!itemToDelete && db.other?.[id]) { + itemToDelete = db.other[id]; + delete db.other[id]; + } + } + + if (itemToDelete) { + saveDownloadsDatabase(db); + } + + return itemToDelete; +} + +/** + * Clear all downloaded items from the database + */ +export function clearAllDownloadedItems(): void { + saveDownloadsDatabase({ movies: {}, series: {}, other: {} }); +} diff --git a/providers/Downloads/fileOperations.ts b/providers/Downloads/fileOperations.ts new file mode 100644 index 00000000..8ef5deb8 --- /dev/null +++ b/providers/Downloads/fileOperations.ts @@ -0,0 +1,100 @@ +import { Directory, File, Paths } from "expo-file-system"; +import { getAllDownloadedItems, getDownloadedItemById } from "./database"; +import type { DownloadedItem } from "./types"; +import { filePathToUri } from "./utils"; + +/** + * Delete a video file and all associated files (subtitles, trickplay, etc.) + */ +export function deleteVideoFile(filePath: string): void { + try { + const videoFile = new File(filePathToUri(filePath)); + if (videoFile.exists) { + videoFile.delete(); + console.log(`[DELETE] Video file deleted: ${filePath}`); + } + } catch (error) { + console.error("Failed to delete video file:", error); + throw error; + } +} + +/** + * Delete all associated files for a downloaded item + * Includes: video, subtitles, trickplay images + */ +export function deleteAllAssociatedFiles(item: DownloadedItem): void { + try { + // Delete video file + if (item.videoFilePath) { + deleteVideoFile(item.videoFilePath); + } + + // Delete subtitle files + if (item.mediaSource?.MediaStreams) { + for (const stream of item.mediaSource.MediaStreams) { + if ( + stream.Type === "Subtitle" && + stream.DeliveryMethod === "External" && + stream.DeliveryUrl + ) { + try { + const subtitleFilename = stream.DeliveryUrl.split("/").pop(); + if (subtitleFilename) { + const subtitleFile = new File(Paths.document, subtitleFilename); + if (subtitleFile.exists) { + subtitleFile.delete(); + console.log(`[DELETE] Subtitle deleted: ${subtitleFilename}`); + } + } + } catch (error) { + console.error("[DELETE] Failed to delete subtitle:", error); + } + } + } + } + + // Delete trickplay directory + if (item.trickPlayData?.path) { + try { + const trickplayDirName = item.trickPlayData.path.split("/").pop(); + if (trickplayDirName) { + const trickplayDir = new Directory(Paths.document, trickplayDirName); + if (trickplayDir.exists) { + trickplayDir.delete(); + console.log( + `[DELETE] Trickplay directory deleted: ${trickplayDirName}`, + ); + } + } + } catch (error) { + console.error("[DELETE] Failed to delete trickplay directory:", error); + } + } + } catch (error) { + console.error("[DELETE] Error deleting associated files:", error); + throw error; + } +} + +/** + * Get the size of a downloaded item by ID + * Includes video file size and trickplay data size + */ +export function getDownloadedItemSize(id: string): number { + const item = getDownloadedItemById(id); + if (!item) return 0; + + const videoSize = item.videoFileSize || 0; + const trickplaySize = item.trickPlayData?.size || 0; + + return videoSize + trickplaySize; +} + +/** + * Calculate total size of all downloaded items + */ +export function calculateTotalDownloadedSize(): number { + const items = getAllDownloadedItems(); + return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0); +} diff --git a/providers/Downloads/hooks/useDownloadEventHandlers.ts b/providers/Downloads/hooks/useDownloadEventHandlers.ts new file mode 100644 index 00000000..ee9fffbe --- /dev/null +++ b/providers/Downloads/hooks/useDownloadEventHandlers.ts @@ -0,0 +1,345 @@ +import type { Api } from "@jellyfin/sdk"; +import { File } from "expo-file-system"; +import type { MutableRefObject } from "react"; +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import type { + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadProgressEvent, + DownloadStartedEvent, +} from "@/modules"; +import { BackgroundDownloader } from "@/modules"; +import { addDownloadedItem } from "../database"; +import { + getNotificationContent, + sendDownloadNotification, +} from "../notifications"; +import type { DownloadedItem, JobStatus } from "../types"; +import { filePathToUri, generateFilename } from "../utils"; +import { + addSpeedDataPoint, + calculateWeightedSpeed, + clearSpeedData, +} from "./useDownloadSpeedCalculator"; + +interface UseDownloadEventHandlersProps { + taskMapRef: MutableRefObject>; + processes: JobStatus[]; + updateProcess: ( + processId: string, + updater: Partial | ((current: JobStatus) => Partial), + ) => void; + removeProcess: (id: string) => void; + onSuccess?: () => void; + onDataChange?: () => void; + api?: Api; +} + +/** + * Hook to set up download event listeners (progress, complete, error, started) + */ +export function useDownloadEventHandlers({ + taskMapRef, + processes, + updateProcess, + removeProcess, + onSuccess, + onDataChange, + api, +}: UseDownloadEventHandlersProps) { + const { t } = useTranslation(); + + // Handle download started events + useEffect(() => { + const startedSub = BackgroundDownloader.addStartedListener( + (event: DownloadStartedEvent) => { + let processId = taskMapRef.current.get(event.taskId); + + // If no mapping exists, find by URL (for queued downloads) + if (!processId && event.url) { + // Check if we have a URL mapping (queued download) + processId = taskMapRef.current.get(event.url); + + if (!processId) { + // Fallback: search by matching URL in processes + const matchingProcess = processes.find( + (p) => p.inputUrl === event.url, + ); + if (matchingProcess) { + processId = matchingProcess.id; + } + } + + if (processId) { + // Create taskId mapping and remove URL mapping + taskMapRef.current.set(event.taskId, processId); + taskMapRef.current.delete(event.url); + console.log( + `[DPL] Mapped queued download: taskId=${event.taskId} to processId=${processId.slice(0, 8)}...`, + ); + } + } + + if (processId) { + updateProcess(processId, { startTime: new Date() }); + } else { + console.warn( + `[DPL] Started event for unknown download: taskId=${event.taskId}, url=${event.url}`, + ); + } + }, + ); + + return () => startedSub.remove(); + }, [taskMapRef, updateProcess, processes]); + + // Track last logged progress per process to avoid spam + const lastLoggedProgress = useRef>(new Map()); + + // Handle download progress events + useEffect(() => { + const progressSub = BackgroundDownloader.addProgressListener( + (event: DownloadProgressEvent) => { + const processId = taskMapRef.current.get(event.taskId); + if (!processId) { + return; + } + + // Validate event data before processing + if ( + typeof event.bytesWritten !== "number" || + event.bytesWritten < 0 || + !Number.isFinite(event.bytesWritten) + ) { + return; + } + + if ( + typeof event.progress !== "number" || + event.progress < 0 || + event.progress > 1 || + !Number.isFinite(event.progress) + ) { + return; + } + + // Add data point and calculate speed (validation happens inside) + addSpeedDataPoint(processId, event.bytesWritten); + const speed = calculateWeightedSpeed(processId); + + // Determine if transcoding based on whether server provides total size + const isTranscoding = !( + event.totalBytes > 0 && Number.isFinite(event.totalBytes) + ); + + // Calculate total size - use actual from server or estimate from bitrate + let estimatedTotalBytes: number | undefined; + if (!isTranscoding) { + // Server provided total size (direct download) + estimatedTotalBytes = event.totalBytes; + } else { + // Transcoding - estimate from bitrate + const process = processes.find((p) => p.id === processId); + console.log( + `[DPL] Transcoding detected, looking for process ${processId}, found:`, + process ? "yes" : "no", + ); + if (process) { + console.log(`[DPL] Process bitrate:`, { + key: process.maxBitrate.key, + value: process.maxBitrate.value, + runTimeTicks: process.item.RunTimeTicks, + }); + if (process.maxBitrate.value && process.item.RunTimeTicks) { + const { estimateDownloadSize } = require("@/utils/download"); + estimatedTotalBytes = estimateDownloadSize( + process.maxBitrate.value, + process.item.RunTimeTicks, + ); + console.log( + `[DPL] Calculated estimatedTotalBytes:`, + estimatedTotalBytes, + ); + } else { + console.log( + `[DPL] Cannot estimate size - bitrate.value or RunTimeTicks missing`, + ); + } + } + } + + // Calculate progress - use native progress if available, otherwise calculate from bytes + let progress: number; + if (event.progress > 0) { + // Server provided total size, use native progress + progress = Math.min(Math.floor(event.progress * 100), 99); + } else if (estimatedTotalBytes && event.bytesWritten > 0) { + // Calculate progress from estimated size + progress = Math.min( + Math.floor((event.bytesWritten / estimatedTotalBytes) * 100), + 99, + ); + } else { + // No way to calculate progress + progress = 0; + } + + // Only log when crossing 10% milestones (not on every update at that milestone) + const lastProgress = lastLoggedProgress.current.get(processId) ?? -1; + const progressMilestone = Math.floor(progress / 10) * 10; + const lastMilestone = Math.floor(lastProgress / 10) * 10; + + // Log when crossing a milestone, or when first hitting 99% + const shouldLog = + progressMilestone !== lastMilestone || + (progress === 99 && lastProgress < 99); + + if (shouldLog) { + console.log( + `[DPL] ${processId.slice(0, 8)}... ${progress}% (${(event.bytesWritten / 1024 / 1024).toFixed(0)}/${estimatedTotalBytes ? (estimatedTotalBytes / 1024 / 1024).toFixed(0) : "?"}MB @ ${speed ? (speed / 1024 / 1024).toFixed(1) : "?"}MB/s)`, + ); + lastLoggedProgress.current.set(processId, progress); + } + + // Update state (native layer already throttles events to every 500ms) + updateProcess(processId, { + progress, + bytesDownloaded: event.bytesWritten, + lastProgressUpdateTime: new Date(), + speed, + estimatedTotalSizeBytes: estimatedTotalBytes, + isTranscoding, + }); + }, + ); + + return () => progressSub.remove(); + }, [taskMapRef, updateProcess, processes]); + + // Handle download completion events + useEffect(() => { + const completeSub = BackgroundDownloader.addCompleteListener( + async (event: DownloadCompleteEvent) => { + const processId = taskMapRef.current.get(event.taskId); + if (!processId) return; + + const process = processes.find((p) => p.id === processId); + if (!process) return; + + try { + const { + item, + mediaSource, + trickPlayData, + introSegments, + creditSegments, + } = process; + const videoFile = new File(filePathToUri(event.filePath)); + const fileInfo = videoFile.info(); + const videoFileSize = fileInfo.size || 0; + const filename = generateFilename(item); + + console.log( + `[COMPLETE] Video download complete (${videoFileSize} bytes) for ${item.Name}`, + ); + console.log( + `[COMPLETE] Using pre-downloaded assets: trickplay=${!!trickPlayData}, intro=${!!introSegments}, credits=${!!creditSegments}`, + ); + + const downloadedItem: DownloadedItem = { + item, + mediaSource, + videoFilePath: filePathToUri(event.filePath), + videoFileSize, + videoFileName: `${filename}.mp4`, + trickPlayData, + introSegments, + creditSegments, + userData: { + audioStreamIndex: 0, + subtitleStreamIndex: 0, + }, + }; + + addDownloadedItem(downloadedItem); + + updateProcess(processId, { + status: "completed", + progress: 100, + }); + + const notificationContent = getNotificationContent(item, true, t); + await sendDownloadNotification( + notificationContent.title, + notificationContent.body, + ); + + onSuccess?.(); + onDataChange?.(); + + // Clean up speed data when download completes + clearSpeedData(processId); + + // Remove process after short delay + setTimeout(() => { + removeProcess(processId); + }, 2000); + } catch (error) { + console.error("Error handling download completion:", error); + updateProcess(processId, { status: "error" }); + clearSpeedData(processId); + removeProcess(processId); + } + }, + ); + + return () => completeSub.remove(); + }, [ + taskMapRef, + processes, + updateProcess, + removeProcess, + onSuccess, + onDataChange, + api, + t, + ]); + + // Handle download error events + useEffect(() => { + const errorSub = BackgroundDownloader.addErrorListener( + async (event: DownloadErrorEvent) => { + const processId = taskMapRef.current.get(event.taskId); + if (!processId) return; + + const process = processes.find((p) => p.id === processId); + if (!process) return; + + console.error(`Download error for ${processId}:`, event.error); + + updateProcess(processId, { status: "error" }); + + // Clean up speed data + clearSpeedData(processId); + + const notificationContent = getNotificationContent( + process.item, + false, + t, + ); + await sendDownloadNotification( + notificationContent.title, + notificationContent.body, + ); + + // Remove process after short delay + setTimeout(() => { + removeProcess(processId); + }, 3000); + }, + ); + + return () => errorSub.remove(); + }, [taskMapRef, processes, updateProcess, removeProcess, t]); +} diff --git a/providers/Downloads/hooks/useDownloadOperations.ts b/providers/Downloads/hooks/useDownloadOperations.ts new file mode 100644 index 00000000..73382e2d --- /dev/null +++ b/providers/Downloads/hooks/useDownloadOperations.ts @@ -0,0 +1,305 @@ +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { File, Paths } from "expo-file-system"; +import type { MutableRefObject } from "react"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner-native"; +import type { Bitrate } from "@/components/BitrateSelector"; +import useImageStorage from "@/hooks/useImageStorage"; +import { BackgroundDownloader } from "@/modules"; +import { getOrSetDeviceId } from "@/utils/device"; +import useDownloadHelper from "@/utils/download"; +import { downloadAdditionalAssets } from "../additionalDownloads"; +import { + clearAllDownloadedItems, + getAllDownloadedItems, + removeDownloadedItem, +} from "../database"; +import { + calculateTotalDownloadedSize, + deleteAllAssociatedFiles, +} from "../fileOperations"; +import type { JobStatus } from "../types"; +import { generateFilename, uriToFilePath } from "../utils"; + +interface UseDownloadOperationsProps { + taskMapRef: MutableRefObject>; + processes: JobStatus[]; + setProcesses: (updater: (prev: JobStatus[]) => JobStatus[]) => void; + removeProcess: (id: string) => void; + api: any; + authHeader?: string; + onDataChange?: () => void; +} + +/** + * Hook providing download operation functions (start, cancel, delete) + */ +export function useDownloadOperations({ + taskMapRef, + processes, + setProcesses, + removeProcess, + api, + authHeader, + onDataChange, +}: UseDownloadOperationsProps) { + const { t } = useTranslation(); + const { saveSeriesPrimaryImage } = useDownloadHelper(); + const { saveImage } = useImageStorage(); + + const startBackgroundDownload = useCallback( + async ( + url: string, + item: BaseItemDto, + mediaSource: MediaSourceInfo, + maxBitrate: Bitrate, + ) => { + if (!api || !item.Id || !authHeader) { + console.warn("startBackgroundDownload ~ Missing required params"); + throw new Error("startBackgroundDownload ~ Missing required params"); + } + + try { + const deviceId = getOrSetDeviceId(); + const processId = item.Id; + + // Check if already downloading + const existingProcess = processes.find((p) => p.id === processId); + if (existingProcess) { + toast.info( + t("home.downloads.toasts.item_already_downloading", { + item: item.Name, + }), + ); + return; + } + + // Download all additional assets BEFORE starting native video download + const additionalAssets = await downloadAdditionalAssets({ + item, + mediaSource, + api, + saveImageFn: saveImage, + saveSeriesImageFn: saveSeriesPrimaryImage, + }); + + // Ensure URL is absolute (not relative) before storing + let downloadUrl = url; + if (url.startsWith("/")) { + const basePath = api.basePath || ""; + downloadUrl = `${basePath}${url}`; + console.log( + `[DOWNLOAD] Converted relative URL to absolute: ${downloadUrl}`, + ); + } + + // Create job status with pre-downloaded assets + const jobStatus: JobStatus = { + id: processId, + inputUrl: downloadUrl, + item, + itemId: item.Id, + deviceId, + progress: 0, + status: "downloading", + timestamp: new Date(), + mediaSource: additionalAssets.updatedMediaSource, + maxBitrate, + bytesDownloaded: 0, + trickPlayData: additionalAssets.trickPlayData, + introSegments: additionalAssets.introSegments, + creditSegments: additionalAssets.creditSegments, + }; + + // Add to processes + setProcesses((prev) => [...prev, jobStatus]); + + // Generate destination path + const filename = generateFilename(item); + const videoFile = new File(Paths.document, `${filename}.mp4`); + const destinationPath = uriToFilePath(videoFile.uri); + + console.log(`[DOWNLOAD] Starting video: ${item.Name}`); + console.log(`[DOWNLOAD] Download URL: ${downloadUrl}`); + + // Start the download using enqueueDownload for sequential processing + const taskId = await BackgroundDownloader.enqueueDownload( + downloadUrl, + destinationPath, + ); + + // Map task ID or URL for later cancellation + if (taskId !== -1) { + taskMapRef.current.set(taskId, processId); + } else { + // For queued downloads, store a negative mapping using URL hash + // This allows us to cancel queued downloads by URL + taskMapRef.current.set(downloadUrl, processId); + } + + toast.success( + t("home.downloads.toasts.download_started_for_item", { + item: item.Name, + }), + ); + } catch (error) { + console.error("Failed to start download:", error); + toast.error(t("home.downloads.toasts.failed_to_start_download"), { + description: error instanceof Error ? error.message : "Unknown error", + }); + throw error; + } + }, + [api, authHeader, processes, setProcesses, taskMapRef, t], + ); + + const cancelDownload = useCallback( + async (id: string) => { + // Find the task ID or URL for this process + let taskId: number | undefined; + let downloadUrl: string | undefined; + + taskMapRef.current.forEach((pId, key) => { + if (pId === id) { + if (typeof key === "number") { + taskId = key; + } else { + downloadUrl = key; + } + } + }); + + if (taskId !== undefined) { + // Cancel active download by taskId + BackgroundDownloader.cancelDownload(taskId); + taskMapRef.current.delete(taskId); + } else if (downloadUrl !== undefined) { + // Cancel queued download by URL + BackgroundDownloader.cancelQueuedDownload(downloadUrl); + taskMapRef.current.delete(downloadUrl); + } + + removeProcess(id); + toast.info(t("home.downloads.toasts.download_cancelled")); + }, + [taskMapRef, removeProcess, t], + ); + + const deleteFile = useCallback( + async (id: string) => { + const itemToDelete = removeDownloadedItem(id); + + if (itemToDelete) { + try { + deleteAllAssociatedFiles(itemToDelete); + toast.success( + t("home.downloads.toasts.file_deleted", { + item: itemToDelete.item.Name, + }), + ); + onDataChange?.(); + } catch (error) { + console.error("Failed to delete files:", error); + } + } + }, + [t, onDataChange], + ); + + const deleteItems = useCallback( + async (ids: string[]) => { + for (const id of ids) { + await deleteFile(id); + } + }, + [deleteFile], + ); + + const deleteAllFiles = useCallback(async () => { + const allItems = getAllDownloadedItems(); + + for (const item of allItems) { + try { + deleteAllAssociatedFiles(item); + } catch (error) { + console.error("Failed to delete file:", error); + } + } + + clearAllDownloadedItems(); + toast.success(t("home.downloads.toasts.all_files_deleted")); + onDataChange?.(); + }, [t, onDataChange]); + + const deleteFileByType = useCallback( + async (itemType: string) => { + const allItems = getAllDownloadedItems(); + const itemsToDelete = allItems.filter( + (item) => item.item.Type === itemType, + ); + + if (itemsToDelete.length === 0) { + console.log(`[DELETE] No items found with type: ${itemType}`); + return; + } + + console.log( + `[DELETE] Deleting ${itemsToDelete.length} items of type: ${itemType}`, + ); + + for (const item of itemsToDelete) { + try { + deleteAllAssociatedFiles(item); + removeDownloadedItem(item.item.Id || ""); + } catch (error) { + console.error( + `Failed to delete ${itemType} file ${item.item.Name}:`, + error, + ); + } + } + + const itemLabel = + itemType === "Movie" + ? t("common.movies") + : itemType === "Episode" + ? t("common.episodes") + : itemType; + + toast.success( + t("home.downloads.toasts.files_deleted_by_type", { + count: itemsToDelete.length, + type: itemLabel, + defaultValue: `${itemsToDelete.length} ${itemLabel} deleted`, + }), + ); + + onDataChange?.(); + }, + [t, onDataChange], + ); + + const appSizeUsage = useCallback(async () => { + const totalSize = calculateTotalDownloadedSize(); + + return { + total: 0, + remaining: 0, + appSize: totalSize, + }; + }, []); + + return { + startBackgroundDownload, + cancelDownload, + deleteFile, + deleteItems, + deleteAllFiles, + deleteFileByType, + appSizeUsage, + }; +} diff --git a/providers/Downloads/hooks/useDownloadSpeedCalculator.ts b/providers/Downloads/hooks/useDownloadSpeedCalculator.ts new file mode 100644 index 00000000..531e1f27 --- /dev/null +++ b/providers/Downloads/hooks/useDownloadSpeedCalculator.ts @@ -0,0 +1,261 @@ +interface SpeedDataPoint { + timestamp: number; + bytesDownloaded: number; +} + +const WINDOW_DURATION = 60000; // 1 minute in ms +const MIN_DATA_POINTS = 5; // Need at least 5 points for accurate speed +const MAX_REASONABLE_SPEED = 1024 * 1024 * 1024; // 1 GB/s sanity check +const EMA_ALPHA = 0.2; // Smoothing factor for EMA (lower = smoother, 0-1 range) + +// Private state +const dataPoints = new Map(); +const emaSpeed = new Map(); // Store EMA speed for each process + +function isValidBytes(bytes: number): boolean { + return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0; +} + +function isValidTimestamp(timestamp: number): boolean { + return ( + typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0 + ); +} + +export function addSpeedDataPoint( + processId: string, + bytesDownloaded: number, +): void { + // Validate input + if (!isValidBytes(bytesDownloaded)) { + console.warn( + `[SpeedCalc] Invalid bytes value for ${processId}: ${bytesDownloaded}`, + ); + return; + } + + const now = Date.now(); + + if (!isValidTimestamp(now)) { + console.warn(`[SpeedCalc] Invalid timestamp: ${now}`); + return; + } + + if (!dataPoints.has(processId)) { + dataPoints.set(processId, []); + } + + const points = dataPoints.get(processId)!; + + // Validate that bytes are increasing (or at least not decreasing) + if (points.length > 0) { + const lastPoint = points[points.length - 1]; + if (bytesDownloaded < lastPoint.bytesDownloaded) { + console.warn( + `[SpeedCalc] Bytes decreased for ${processId}: ${lastPoint.bytesDownloaded} -> ${bytesDownloaded}. Resetting.`, + ); + // Reset the data for this process + dataPoints.set(processId, []); + } + } + + // Add new data point + points.push({ + timestamp: now, + bytesDownloaded, + }); + + // Remove data points older than 1 minute + const cutoffTime = now - WINDOW_DURATION; + while (points.length > 0 && points[0].timestamp < cutoffTime) { + points.shift(); + } +} + +export function calculateSpeed(processId: string): number | undefined { + const points = dataPoints.get(processId); + + if (!points || points.length < MIN_DATA_POINTS) { + return undefined; + } + + const oldest = points[0]; + const newest = points[points.length - 1]; + + // Validate data points + if ( + !isValidBytes(oldest.bytesDownloaded) || + !isValidBytes(newest.bytesDownloaded) || + !isValidTimestamp(oldest.timestamp) || + !isValidTimestamp(newest.timestamp) + ) { + console.warn(`[SpeedCalc] Invalid data points for ${processId}`); + return undefined; + } + + const timeDelta = (newest.timestamp - oldest.timestamp) / 1000; // seconds + const bytesDelta = newest.bytesDownloaded - oldest.bytesDownloaded; + + // Validate calculations + if (timeDelta < 0.5) { + // Not enough time has passed + return undefined; + } + + if (bytesDelta < 0) { + console.warn( + `[SpeedCalc] Negative bytes delta for ${processId}: ${bytesDelta}`, + ); + return undefined; + } + + const speed = bytesDelta / timeDelta; // bytes per second + + // Sanity check: if speed is unrealistically high, something is wrong + if (!Number.isFinite(speed) || speed < 0 || speed > MAX_REASONABLE_SPEED) { + console.warn(`[SpeedCalc] Unrealistic speed for ${processId}: ${speed}`); + return undefined; + } + + return speed; +} + +// Calculate weighted average speed (more recent data has higher weight) +export function calculateWeightedSpeed(processId: string): number | undefined { + const points = dataPoints.get(processId); + + if (!points || points.length < MIN_DATA_POINTS) { + return undefined; + } + + let totalWeightedSpeed = 0; + let totalWeight = 0; + + // Calculate speed between consecutive points with exponential weighting + for (let i = 1; i < points.length; i++) { + const prevPoint = points[i - 1]; + const currPoint = points[i]; + + // Validate both points + if ( + !isValidBytes(prevPoint.bytesDownloaded) || + !isValidBytes(currPoint.bytesDownloaded) || + !isValidTimestamp(prevPoint.timestamp) || + !isValidTimestamp(currPoint.timestamp) + ) { + continue; + } + + const timeDelta = (currPoint.timestamp - prevPoint.timestamp) / 1000; + const bytesDelta = currPoint.bytesDownloaded - prevPoint.bytesDownloaded; + + // Skip invalid deltas + if (timeDelta < 0.1 || bytesDelta < 0) { + continue; + } + + const speed = bytesDelta / timeDelta; + + // Sanity check + if (!Number.isFinite(speed) || speed < 0 || speed > MAX_REASONABLE_SPEED) { + console.warn(`[SpeedCalc] Skipping unrealistic speed point: ${speed}`); + continue; + } + + // More recent points get exponentially higher weight + // Using 1.3 instead of 2 for gentler weighting (less sensitive to recent changes) + const weight = 1.3 ** i; + totalWeightedSpeed += speed * weight; + totalWeight += weight; + } + + if (totalWeight === 0) { + return undefined; + } + + const weightedSpeed = totalWeightedSpeed / totalWeight; + + // Final sanity check + if (!Number.isFinite(weightedSpeed) || weightedSpeed < 0) { + return undefined; + } + + return weightedSpeed; +} + +// Calculate ETA in seconds +export function calculateETA( + processId: string, + bytesDownloaded: number, + totalBytes: number, +): number | undefined { + const speed = calculateWeightedSpeed(processId); + + if (!speed || speed <= 0 || !totalBytes || totalBytes <= 0) { + return undefined; + } + + const bytesRemaining = totalBytes - bytesDownloaded; + if (bytesRemaining <= 0) { + return 0; + } + + const secondsRemaining = bytesRemaining / speed; + + // Sanity check + if (!Number.isFinite(secondsRemaining) || secondsRemaining < 0) { + return undefined; + } + + return secondsRemaining; +} + +// Calculate smoothed ETA using Exponential Moving Average (EMA) +// This provides much smoother ETA estimates, reducing jumpy time estimates +const emaETA = new Map(); + +export function calculateSmoothedETA( + processId: string, + bytesDownloaded: number, + totalBytes: number, +): number | undefined { + const currentETA = calculateETA(processId, bytesDownloaded, totalBytes); + + if (currentETA === undefined) { + return undefined; + } + + const previousEma = emaETA.get(processId); + + if (previousEma === undefined) { + // First calculation, initialize with current ETA + emaETA.set(processId, currentETA); + return currentETA; + } + + // EMA formula: EMA(t) = α * current + (1 - α) * EMA(t-1) + // Lower alpha = smoother but slower to respond + const smoothed = EMA_ALPHA * currentETA + (1 - EMA_ALPHA) * previousEma; + + emaETA.set(processId, smoothed); + return smoothed; +} + +export function clearSpeedData(processId: string): void { + dataPoints.delete(processId); + emaSpeed.delete(processId); + emaETA.delete(processId); +} + +export function resetAllSpeedData(): void { + dataPoints.clear(); + emaSpeed.clear(); + emaETA.clear(); +} + +// Debug function to inspect current state +export function getSpeedDataDebug( + processId: string, +): SpeedDataPoint[] | undefined { + return dataPoints.get(processId); +} diff --git a/providers/Downloads/index.ts b/providers/Downloads/index.ts new file mode 100644 index 00000000..198cfd06 --- /dev/null +++ b/providers/Downloads/index.ts @@ -0,0 +1,47 @@ +// Database operations + +// Additional downloads (trickplay, subtitles, cover images) +export { + downloadAdditionalAssets, + downloadCoverImage, + downloadSeriesImage, + downloadSubtitles, + downloadTrickplayImages, + fetchSegments, +} from "./additionalDownloads"; +export { + addDownloadedItem, + clearAllDownloadedItems, + getAllDownloadedItems, + getDownloadedItemById, + getDownloadsDatabase, + removeDownloadedItem, + saveDownloadsDatabase, +} from "./database"; +// File operations +export { + calculateTotalDownloadedSize, + deleteAllAssociatedFiles, + deleteVideoFile, + getDownloadedItemSize, +} from "./fileOperations"; +// Hooks +export { useDownloadEventHandlers } from "./hooks/useDownloadEventHandlers"; +export { useDownloadOperations } from "./hooks/useDownloadOperations"; +// Notification helpers +export { + getNotificationContent, + sendDownloadNotification, +} from "./notifications"; +// Types (re-export from existing types.ts) +export type { + DownloadedItem, + DownloadedSeason, + DownloadedSeries, + DownloadsDatabase, + JobStatus, + MediaTimeSegment, + TrickPlayData, +} from "./types"; +// Utility functions +export { generateFilename, uriToFilePath } from "./utils"; diff --git a/providers/Downloads/notifications.ts b/providers/Downloads/notifications.ts new file mode 100644 index 00000000..6b1acef6 --- /dev/null +++ b/providers/Downloads/notifications.ts @@ -0,0 +1,78 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import * as Notifications from "expo-notifications"; +import type { TFunction } from "i18next"; +import { Platform } from "react-native"; + +/** + * Generate notification content based on item type + */ +export function getNotificationContent( + item: BaseItemDto, + isSuccess: boolean, + t: TFunction, +): { title: string; body: string } { + if (item.Type === "Episode") { + const season = item.ParentIndexNumber + ? String(item.ParentIndexNumber).padStart(2, "0") + : "??"; + const episode = item.IndexNumber + ? String(item.IndexNumber).padStart(2, "0") + : "??"; + const subtitle = `${item.Name} - [S${season}E${episode}] (${item.SeriesName})`; + + return { + title: isSuccess + ? t("home.downloads.toasts.download_completed") + : t("home.downloads.toasts.download_failed"), + body: subtitle, + }; + } + + if (item.Type === "Movie") { + const year = item.ProductionYear ? ` (${item.ProductionYear})` : ""; + const subtitle = `${item.Name}${year}`; + + return { + title: isSuccess + ? t("home.downloads.toasts.download_completed") + : t("home.downloads.toasts.download_failed"), + body: subtitle, + }; + } + + return { + title: isSuccess + ? t("home.downloads.toasts.download_completed_for_item", { + item: item.Name, + }) + : t("home.downloads.toasts.download_failed_for_item", { + item: item.Name, + }), + body: item.Name || "Unknown item", + }; +} + +/** + * Send a local notification for download events + */ +export async function sendDownloadNotification( + title: string, + body: string, + data?: Record, +): Promise { + if (Platform.isTV) return; + + try { + await Notifications.scheduleNotificationAsync({ + content: { + title, + body, + data: data || {}, // iOS requires data to be an object, not undefined + ...(Platform.OS === "android" && { channelId: "downloads" }), + }, + trigger: null, + }); + } catch (error) { + console.error("Failed to send notification:", error); + } +} diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts index 8eb6833f..de9d074e 100644 --- a/providers/Downloads/types.ts +++ b/providers/Downloads/types.ts @@ -46,6 +46,8 @@ export interface DownloadedItem { videoFilePath: string; /** The size of the video file in bytes. */ videoFileSize: number; + /** The video filename (for easy File object reconstruction). Optional for backwards compatibility. */ + videoFileName?: string; /** The local file path of the downloaded trickplay images. */ trickPlayData?: TrickPlayData; /** The intro segments for the item. */ @@ -111,7 +113,6 @@ export type JobStatus = { /** Current status of the download job */ status: | "downloading" // The job is actively downloading - | "paused" // The job is paused | "error" // The job encountered an error | "pending" // The job is waiting to start | "completed" // The job has finished downloading @@ -131,14 +132,14 @@ export type JobStatus = { /** Estimated total size of the download in bytes (optional) this is used when we * download transcoded content because we don't know the size of the file until it's downloaded */ estimatedTotalSizeBytes?: number; - /** Timestamp when the download was paused (optional) */ - pausedAt?: Date; - /** Progress percentage when download was paused (optional) */ - pausedProgress?: number; - /** Bytes downloaded when download was paused (optional) */ - pausedBytes?: number; - /** Bytes downloaded in the current session (since last resume). Used for session-only speed calculation. */ - lastSessionBytes?: number; - /** Timestamp when the session-only bytes were last updated. */ - lastSessionUpdateTime?: Date; + /** Timestamp of when the download actually started (optional) */ + startTime?: Date; + /** Whether the download is being transcoded (optional) */ + isTranscoding?: boolean; + /** Pre-downloaded trickplay data (optional) - downloaded before video starts */ + trickPlayData?: TrickPlayData; + /** Pre-downloaded intro segments (optional) - downloaded before video starts */ + introSegments?: MediaTimeSegment[]; + /** Pre-downloaded credit segments (optional) - downloaded before video starts */ + creditSegments?: MediaTimeSegment[]; }; diff --git a/providers/Downloads/utils.ts b/providers/Downloads/utils.ts new file mode 100644 index 00000000..d3de52c6 --- /dev/null +++ b/providers/Downloads/utils.ts @@ -0,0 +1,44 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; + +/** + * Generate a safe filename from item metadata + */ +export function generateFilename(item: BaseItemDto): string { + if (item.Type === "Episode") { + const season = String(item.ParentIndexNumber || 0).padStart(2, "0"); + const episode = String(item.IndexNumber || 0).padStart(2, "0"); + const seriesName = (item.SeriesName || "Unknown") + .replace(/[^a-z0-9]/gi, "_") + .toLowerCase(); + return `${seriesName}_s${season}e${episode}`; + } + + if (item.Type === "Movie") { + const movieName = (item.Name || "Unknown") + .replace(/[^a-z0-9]/gi, "_") + .toLowerCase(); + const year = item.ProductionYear || ""; + return `${movieName}_${year}`; + } + + return `${item.Id}`; +} + +/** + * Strip file:// prefix from URI to get plain file path + * Required for native modules that expect plain paths + */ +export function uriToFilePath(uri: string): string { + return uri.replace(/^file:\/\//, ""); +} + +/** + * Convert plain file path to file:// URI + * Required for expo-file-system File constructor + */ +export function filePathToUri(path: string): string { + if (path.startsWith("file://")) { + return path; + } + return `file://${path}`; +} diff --git a/providers/GlobalModalProvider.tsx b/providers/GlobalModalProvider.tsx new file mode 100644 index 00000000..d25b4fe2 --- /dev/null +++ b/providers/GlobalModalProvider.tsx @@ -0,0 +1,95 @@ +import type { BottomSheetModal } from "@gorhom/bottom-sheet"; +import type React from "react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useRef, + useState, +} from "react"; + +interface ModalOptions { + enableDynamicSizing?: boolean; + snapPoints?: (string | number)[]; + enablePanDownToClose?: boolean; + backgroundStyle?: object; + handleIndicatorStyle?: object; +} + +interface GlobalModalState { + content: ReactNode | null; + options?: ModalOptions; +} + +interface GlobalModalContextType { + showModal: (content: ReactNode, options?: ModalOptions) => void; + hideModal: () => void; + isVisible: boolean; + modalState: GlobalModalState; + modalRef: React.RefObject; +} + +const GlobalModalContext = createContext( + undefined, +); + +export const useGlobalModal = () => { + const context = useContext(GlobalModalContext); + if (!context) { + throw new Error("useGlobalModal must be used within GlobalModalProvider"); + } + return context; +}; + +interface GlobalModalProviderProps { + children: ReactNode; +} + +export const GlobalModalProvider: React.FC = ({ + children, +}) => { + const [modalState, setModalState] = useState({ + content: null, + options: undefined, + }); + const [isVisible, setIsVisible] = useState(false); + const modalRef = useRef(null); + + const showModal = useCallback( + (content: ReactNode, options?: ModalOptions) => { + setModalState({ content, options }); + setIsVisible(true); + // Wait for state update and layout to complete before presenting + requestAnimationFrame(() => { + modalRef.current?.present(); + }); + }, + [], + ); + + const hideModal = useCallback(() => { + modalRef.current?.dismiss(); + setIsVisible(false); + // Clear content after dismiss animation completes + requestAnimationFrame(() => { + setModalState({ content: null, options: undefined }); + }); + }, []); + + const value = { + showModal, + hideModal, + isVisible, + modalState, + modalRef, + }; + + return ( + + {children} + + ); +}; + +export type { GlobalModalContextType, ModalOptions }; diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 6bc92bd2..76357921 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.40.4" }, + clientInfo: { name: "Streamyfin", version: "0.46.2" }, deviceInfo: { name: deviceName, id, @@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.40.4"`, + }, DeviceId="${deviceId}", Version="0.46.2"`, }; }, [deviceId]); @@ -203,7 +203,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const removeServerMutation = useMutation({ mutationFn: async () => { - storage.delete("serverUrl"); + storage.remove("serverUrl"); setApi(null); }, onError: (error) => { @@ -286,7 +286,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ writeErrorLog("Failed to delete expo push token for device"), ); - storage.delete("token"); + storage.remove("token"); setUser(null); setApi(null); setPluginSettings(undefined); diff --git a/translations/ar.json b/translations/ar.json index dc54707d..248f1a70 100644 --- a/translations/ar.json +++ b/translations/ar.json @@ -124,7 +124,6 @@ }, "other": { "other_title": "أخرى", - "follow_device_orientation": "تدوير تلقائي", "video_orientation": "اتجاه الفيديو", "orientation": "الاتجاه", "orientations": { @@ -155,9 +154,6 @@ }, "downloads": { "downloads_title": "التنزيلات", - "download_method": "طريقة التنزيل", - "remux_max_download": "الحد الأقصى لتنزيل الريمكس", - "auto_download": "تنزيل تلقائي", "optimized_versions_server": "خادم الإصدارات المحسّنة", "save_button": "حفظ", "optimized_server": "الخادم المحسن", diff --git a/translations/ar.json.tmp b/translations/ar.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/ca.json b/translations/ca.json index c00933eb..3998e20c 100644 --- a/translations/ca.json +++ b/translations/ca.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Altres", - "follow_device_orientation": "Rotació automàtica", "video_orientation": "Orientació del vídeo", "orientation": "Orientació", "orientations": { @@ -144,9 +143,6 @@ }, "downloads": { "downloads_title": "Descàrregues", - "download_method": "Mètode de descàrrega", - "remux_max_download": "Màxima descàrrega remux", - "auto_download": "Descàrrega automàtica", "optimized_versions_server": "Servidor de versions optimitzades", "save_button": "Desa", "optimized_server": "Servidor optimitzat", diff --git a/translations/ca.json.tmp b/translations/ca.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/da.json b/translations/da.json index f7d21d06..3b83cf41 100644 --- a/translations/da.json +++ b/translations/da.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Andet", - "follow_device_orientation": "Auto rotér", "video_orientation": "Videoorientering", "orientation": "Orientering", "orientations": { @@ -142,9 +141,6 @@ }, "downloads": { "downloads_title": "Downloads", - "download_method": "Downloadmetode", - "remux_max_download": "Remux maks download", - "auto_download": "Auto download", "optimized_versions_server": "Optimeret versionsserver", "save_button": "Gem", "optimized_server": "Optimeret server", diff --git a/translations/da.json.tmp b/translations/da.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/de.json b/translations/de.json index a575b31a..8c4e1983 100644 --- a/translations/de.json +++ b/translations/de.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Sonstiges", - "follow_device_orientation": "Automatische Drehung", "video_orientation": "Videoausrichtung", "orientation": "Ausrichtung", "orientations": { @@ -143,9 +142,6 @@ }, "downloads": { "downloads_title": "Downloads", - "download_method": "Download-Methode", - "remux_max_download": "Maximaler Remux-Download", - "auto_download": "Automatischer Download", "optimized_versions_server": "Optimierter Versions-Server", "save_button": "Speichern", "optimized_server": "Optimierter Server", diff --git a/translations/de.json.tmp b/translations/de.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/en.json b/translations/en.json index 148c08c9..97007508 100644 --- a/translations/en.json +++ b/translations/en.json @@ -160,7 +160,6 @@ }, "other": { "other_title": "Other", - "follow_device_orientation": "Auto Rotate", "video_orientation": "Video Orientation", "orientation": "Orientation", "orientations": { @@ -182,7 +181,7 @@ "VLC_4": "VLC 4 (Experimental + PiP)" }, "show_custom_menu_links": "Show Custom Menu Links", - "show_large_home_carousel": "Show Large Home Carousel", + "show_large_home_carousel": "Show Large Home Carousel (beta)", "hide_libraries": "Hide Libraries", "select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.", "disable_haptic_feedback": "Disable Haptic Feedback", @@ -191,8 +190,7 @@ "disabled": "Disabled" }, "downloads": { - "downloads_title": "Downloads", - "remux_max_download": "Remux Max Download" + "downloads_title": "Downloads" }, "plugins": { "plugins_title": "Plugins", @@ -296,20 +294,26 @@ "deleted_media_successfully": "Deleted other media Successfully!", "failed_to_delete_media": "Failed to Delete other media", "download_deleted": "Download Deleted", + "download_cancelled": "Download Cancelled", "could_not_delete_download": "Could Not Delete Download", "download_paused": "Download Paused", "could_not_pause_download": "Could Not Pause Download", "download_resumed": "Download Resumed", "could_not_resume_download": "Could Not Resume Download", "download_completed": "Download Completed", + "download_failed": "Download Failed", "download_failed_for_item": "Download failed for {{item}} - {{error}}", "download_completed_for_item": "Download Completed for {{item}}", "download_started_for_item": "Download Started for {{item}}", "failed_to_start_download": "Failed to start download", + "item_already_downloading": "{{item}} is already downloading", + "all_files_deleted": "All Downloads Deleted Successfully", + "files_deleted_by_type": "{{count}} {{type}} deleted", "all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully", "failed_to_clean_cache_directory": "Failed to clean cache directory", "could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}", - "go_to_downloads": "Go to Downloads" + "go_to_downloads": "Go to Downloads", + "file_deleted": "{{item}} deleted" } } }, diff --git a/translations/en.json.tmp b/translations/en.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/eo.json b/translations/eo.json index 5fcac626..90af14fc 100644 --- a/translations/eo.json +++ b/translations/eo.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Alia", - "follow_device_orientation": "Aŭtomata rotacio", "video_orientation": "Video-orientiĝo", "orientation": "Orientiĝo", "orientations": { @@ -142,9 +141,6 @@ }, "downloads": { "downloads_title": "Elŝutoj", - "download_method": "Elŝuta metodo", - "remux_max_download": "Remux maksimuma elŝuto", - "auto_download": "Aŭtomata elŝuto", "optimized_versions_server": "Optimumigitaj versioj servilo", "save_button": "Konservi", "optimized_server": "Optimumigita Servilo", diff --git a/translations/eo.json.tmp b/translations/eo.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/es.json b/translations/es.json index f7a78f61..8fd53f8f 100644 --- a/translations/es.json +++ b/translations/es.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Otros", - "follow_device_orientation": "Rotación automática", "video_orientation": "Orientación de vídeo", "orientation": "Orientación", "orientations": { @@ -143,9 +142,6 @@ }, "downloads": { "downloads_title": "Descargas", - "download_method": "Método de descarga", - "remux_max_download": "Remux máx. descarga", - "auto_download": "Descarga automática", "optimized_versions_server": "Servidor de versiones optimizadas", "save_button": "Guardar", "optimized_server": "Servidor optimizado", diff --git a/translations/es.json.tmp b/translations/es.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/fi.json b/translations/fi.json index 215ecf59..1660a0b0 100644 --- a/translations/fi.json +++ b/translations/fi.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Muut", - "follow_device_orientation": "Automaattinen kierto", "video_orientation": "Videon suunta", "orientation": "Suunta", "orientations": { @@ -142,9 +141,6 @@ }, "downloads": { "downloads_title": "Lataukset", - "download_method": "Latausmenetelmä", - "remux_max_download": "Remuxin maksimi lataus", - "auto_download": "Automaattinen lataus", "optimized_versions_server": "Optimoitujen versioiden palvelin", "save_button": "Tallenna", "optimized_server": "Optimoitu palvelin", diff --git a/translations/fi.json.tmp b/translations/fi.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/fr.json b/translations/fr.json index e50cc400..803fec86 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Autres", - "follow_device_orientation": "Rotation automatique", "video_orientation": "Orientation vidéo", "orientation": "Orientation", "orientations": { @@ -143,9 +142,6 @@ }, "downloads": { "downloads_title": "Téléchargements", - "download_method": "Méthode de téléchargement", - "remux_max_download": "Téléchargement max remux", - "auto_download": "Téléchargement automatique", "optimized_versions_server": "Serveur de versions optimisées", "save_button": "Enregistrer", "optimized_server": "Serveur optimisé", diff --git a/translations/fr.json.tmp b/translations/fr.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/hu.json b/translations/hu.json index 9306d339..a80b9767 100644 --- a/translations/hu.json +++ b/translations/hu.json @@ -129,7 +129,6 @@ }, "other": { "other_title": "Egyéb", - "follow_device_orientation": "Automatikus Forgatás", "video_orientation": "Videó Tájolás", "orientation": "Tájolás", "orientations": { @@ -159,8 +158,7 @@ "disabled": "Letiltva" }, "downloads": { - "downloads_title": "Letöltések", - "remux_max_download": "Remux Maximális Letöltés" + "downloads_title": "Letöltések" }, "plugins": { "plugins_title": "Bővítmények", diff --git a/translations/hu.json.tmp b/translations/hu.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/it.json b/translations/it.json index e73fd39d..fd1a4060 100644 --- a/translations/it.json +++ b/translations/it.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Altro", - "follow_device_orientation": "Rotazione automatica", "video_orientation": "Orientamento del video", "orientation": "Orientamento", "orientations": { @@ -143,9 +142,6 @@ }, "downloads": { "downloads_title": "Scaricamento", - "download_method": "Metodo per lo scaricamento", - "remux_max_download": "Numero di Remux da scaricare al massimo", - "auto_download": "Scaricamento automatico", "optimized_versions_server": "Versioni del server di ottimizzazione", "save_button": "Salva", "optimized_server": "Server di ottimizzazione", diff --git a/translations/it.json.tmp b/translations/it.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/ja.json b/translations/ja.json index 3151591c..e3ae6dfd 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "その他", - "follow_device_orientation": "画面の自動回転", "video_orientation": "動画の向き", "orientation": "向き", "orientations": { @@ -141,9 +140,6 @@ }, "downloads": { "downloads_title": "ダウンロード", - "download_method": "ダウンロード方法", - "remux_max_download": "Remux最大ダウンロード数", - "auto_download": "自動ダウンロード", "optimized_versions_server": "Optimized versionsサーバー", "save_button": "保存", "optimized_server": "Optimizedサーバー", diff --git a/translations/ja.json.tmp b/translations/ja.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/nb.json b/translations/nb.json index cbbd081b..604615fa 100644 --- a/translations/nb.json +++ b/translations/nb.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Annet", - "follow_device_orientation": "Automatisk rotasjon", "video_orientation": "Videoorientering", "orientation": "Rotasjon", "orientations": { @@ -142,9 +141,6 @@ }, "downloads": { "downloads_title": "Nedlastinger", - "download_method": "Nedlastingsmetode", - "remux_max_download": "Remux max nedlasting", - "auto_download": "Automatisk nedlasting", "optimized_versions_server": "Optimaliserte versjoner server", "save_button": "Lagre", "optimized_server": "Optimalisert Server", diff --git a/translations/nb.json.tmp b/translations/nb.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/nl.json b/translations/nl.json index 01cca337..56183beb 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Andere", - "follow_device_orientation": "Automatisch draaien", "video_orientation": "Video oriëntatie", "orientation": "Oriëntatie", "orientations": { @@ -143,9 +142,6 @@ }, "downloads": { "downloads_title": "Downloads", - "download_method": "Download methode", - "remux_max_download": "Maximale Remux-download", - "auto_download": "Auto download", "optimized_versions_server": "Geoptimaliseerde server versies", "save_button": "Opslaan", "optimized_server": "Geoptimaliseerde Server", diff --git a/translations/nl.json.tmp b/translations/nl.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/nn.json b/translations/nn.json index e3795f00..86da7a9c 100644 --- a/translations/nn.json +++ b/translations/nn.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Anna", - "follow_device_orientation": "Automatisk rotasjon", "video_orientation": "Videoorientering", "orientation": "Rotasjon", "orientations": { @@ -142,9 +141,6 @@ }, "downloads": { "downloads_title": "Nedlastingar", - "download_method": "Nedlastingsmetode", - "remux_max_download": "Remux max nedlasting", - "auto_download": "Automatisk nedlasting", "optimized_versions_server": "Optimaliserte versjonar servar", "save_button": "Lagre", "optimized_server": "Optimalisert Servar", diff --git a/translations/nn.json.tmp b/translations/nn.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/pl.json b/translations/pl.json index 839eb209..c7e0b69a 100644 --- a/translations/pl.json +++ b/translations/pl.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Inne", - "follow_device_orientation": "Podążaj za orientacją urządzenia", "video_orientation": "Orientacja wideo", "orientation": "Orientacja", "orientations": { @@ -143,9 +142,6 @@ }, "downloads": { "downloads_title": "Pobieranie", - "download_method": "Metoda pobierania", - "remux_max_download": "Maksymalne pobieranie remux", - "auto_download": "Automatyczne pobieranie", "optimized_versions_server": "Serwer zoptymalizowanych wersji", "save_button": "Zapisz", "optimized_server": "Serwer zoptymalizowany", diff --git a/translations/pl.json.tmp b/translations/pl.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/pt-BR.json b/translations/pt-BR.json index 4b6efe05..cad50f50 100644 --- a/translations/pt-BR.json +++ b/translations/pt-BR.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Outros", - "follow_device_orientation": "Rotação automática", "video_orientation": "Orientação do vídeo", "orientation": "Orientação", "orientations": { @@ -143,9 +142,6 @@ }, "downloads": { "downloads_title": "Downloads", - "download_method": "Método de download", - "remux_max_download": "Remux max download", - "auto_download": "Download automático", "optimized_versions_server": "Servidor do optimized versions", "save_button": "Salvar", "optimized_server": "Optimized Server", diff --git a/translations/pt-BR.json.tmp b/translations/pt-BR.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/ro.json b/translations/ro.json index 19b2f1fe..dd7ad7a7 100644 --- a/translations/ro.json +++ b/translations/ro.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Altele", - "follow_device_orientation": "Rotire automată", "video_orientation": "Orientarea video", "orientation": "Orientare", "orientations": { @@ -144,9 +143,6 @@ }, "downloads": { "downloads_title": "Descărcări", - "download_method": "Metoda de descărcare", - "remux_max_download": "Remux max download", - "auto_download": "Descărcare automată", "optimized_versions_server": "Optimized versions server", "save_button": "Salvează", "optimized_server": "Server optimizat", diff --git a/translations/ro.json.tmp b/translations/ro.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/ru.json b/translations/ru.json index 3b78c2a9..cbd67832 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Другое", - "follow_device_orientation": "Авто-поворот", "video_orientation": "Ориентация видео", "orientation": "Ориентация", "orientations": { @@ -143,9 +142,6 @@ }, "downloads": { "downloads_title": "Загрузки", - "download_method": "способ загрузки", - "remux_max_download": "Remux max скачать", - "auto_download": "Авто-загрузка", "optimized_versions_server": "Оптимизированные версии сервера", "save_button": "Сохранить", "optimized_server": "Оптимизированный сервер", diff --git a/translations/ru.json.tmp b/translations/ru.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/sq.json b/translations/sq.json index 51bf7f6f..09ac58fe 100644 --- a/translations/sq.json +++ b/translations/sq.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Tjetër", - "follow_device_orientation": "Auto rrotullimi", "video_orientation": "Orientimi i videos", "orientation": "Orientimi", "orientations": { @@ -142,9 +141,6 @@ }, "downloads": { "downloads_title": "Shkarkime", - "download_method": "Metoda e shkarkimit", - "remux_max_download": "Remux maks shkarkimi", - "auto_download": "Shkarkim automatik", "optimized_versions_server": "Serveri i versioneve të optimizuara", "save_button": "Ruaj", "optimized_server": "Server i optimizuar", diff --git a/translations/sq.json.tmp b/translations/sq.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/sv.json.tmp b/translations/sv.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/tlh.json b/translations/tlh.json index ac996c77..e346f47a 100644 --- a/translations/tlh.json +++ b/translations/tlh.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "patlh", - "follow_device_orientation": "naDevvo' pegh", "video_orientation": "mu'tlhegh pegh", "orientation": "pegh", "orientations": { @@ -142,9 +141,6 @@ }, "downloads": { "downloads_title": "Qaw' Doch", - "download_method": "Qaw' mIw", - "remux_max_download": "Remux Qaw' Dun", - "auto_download": "chIch Qaw'", "optimized_versions_server": "luj wej Ho'Do' veS", "save_button": "yIqIp", "optimized_server": "luj Ho'Do' veS", diff --git a/translations/tlh.json.tmp b/translations/tlh.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/tr.json b/translations/tr.json index ea08de58..01762b0b 100644 --- a/translations/tr.json +++ b/translations/tr.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Diğer", - "follow_device_orientation": "Otomatik Döndürme", "video_orientation": "Video Yönü", "orientation": "Yön", "orientations": { @@ -143,9 +142,6 @@ }, "downloads": { "downloads_title": "İndirmeler", - "download_method": "İndirme Yöntemi", - "remux_max_download": "Remux max indirme", - "auto_download": "Otomatik İndirme", "optimized_versions_server": "Optimize edilmiş sürümler sunucusu", "save_button": "Kaydet", "optimized_server": "Optimize Sunucu", diff --git a/translations/tr.json.tmp b/translations/tr.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/uk.json b/translations/uk.json index 96cdcb67..0c497343 100644 --- a/translations/uk.json +++ b/translations/uk.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Інші", - "follow_device_orientation": "Дотримуйтесь орієнтації пристрою", "video_orientation": "Орієнтація відео", "orientation": "Orientation", "orientations": { @@ -143,9 +142,6 @@ }, "downloads": { "downloads_title": "Завантаження", - "download_method": "Метод завантаження", - "remux_max_download": "Remux max download", - "auto_download": "Авто-завантаження", "optimized_versions_server": "Сервер оптимізованих версій", "save_button": "Зберегти", "optimized_server": "Оптимізований Сервер", diff --git a/translations/uk.json.tmp b/translations/uk.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/vi.json b/translations/vi.json index b0ef4dd9..2112f5c0 100644 --- a/translations/vi.json +++ b/translations/vi.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "Khác", - "follow_device_orientation": "Tự xoay màn hình", "video_orientation": "Hướng video", "orientation": "Hướng", "orientations": { @@ -144,9 +143,6 @@ }, "downloads": { "downloads_title": "Tải xuống", - "download_method": "Phương pháp tải", - "remux_max_download": "Giới hạn tải Remux tối đa", - "auto_download": "Tự động tải", "optimized_versions_server": "Máy chủ phiên bản tối ưu", "save_button": "Lưu", "optimized_server": "Máy chủ tối ưu", diff --git a/translations/vi.json.tmp b/translations/vi.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/zh-CN.json b/translations/zh-CN.json index 6a0a2c11..2581e94d 100644 --- a/translations/zh-CN.json +++ b/translations/zh-CN.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "其他", - "follow_device_orientation": "自动旋转", "video_orientation": "视频方向", "orientation": "方向", "orientations": { @@ -141,9 +140,6 @@ }, "downloads": { "downloads_title": "下载", - "download_method": "下载方法", - "remux_max_download": "Remux 最大下载", - "auto_download": "自动下载", "optimized_versions_server": "Optimized Version 服务器", "save_button": "保存", "optimized_server": "Optimized Server", diff --git a/translations/zh-CN.json.tmp b/translations/zh-CN.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/translations/zh-TW.json b/translations/zh-TW.json index 1a2ba018..0f311af7 100644 --- a/translations/zh-TW.json +++ b/translations/zh-TW.json @@ -113,7 +113,6 @@ }, "other": { "other_title": "其他", - "follow_device_orientation": "自動旋轉", "video_orientation": "影片方向", "orientation": "方向", "orientations": { @@ -144,9 +143,6 @@ }, "downloads": { "downloads_title": "下載", - "download_method": "下載方法", - "remux_max_download": "Remux 最大下載", - "auto_download": "自動下載", "optimized_versions_server": "Optimized Version 伺服器", "save_button": "保存", "optimized_server": "Optimized Server", diff --git a/translations/zh-TW.json.tmp b/translations/zh-TW.json.tmp new file mode 100644 index 00000000..e69de29b diff --git a/utils/OrientationLockConverter.ts b/utils/OrientationLockConverter.ts deleted file mode 100644 index a386853e..00000000 --- a/utils/OrientationLockConverter.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - Orientation, - OrientationLock, -} from "@/packages/expo-screen-orientation"; - -function orientationToOrientationLock( - orientation: Orientation, -): OrientationLock { - switch (orientation) { - case Orientation.PORTRAIT_UP: - return OrientationLock.PORTRAIT_UP; - case Orientation.PORTRAIT_DOWN: - return OrientationLock.PORTRAIT_DOWN; - case Orientation.LANDSCAPE_LEFT: - return OrientationLock.LANDSCAPE_LEFT; - case Orientation.LANDSCAPE_RIGHT: - return OrientationLock.LANDSCAPE_RIGHT; - default: - return OrientationLock.DEFAULT; - } -} - -export default orientationToOrientationLock; diff --git a/utils/atoms/filters.ts b/utils/atoms/filters.ts index df9c5c78..8f1f8dae 100644 --- a/utils/atoms/filters.ts +++ b/utils/atoms/filters.ts @@ -91,7 +91,7 @@ export const sortByPreferenceAtom = atomWithStorage( storage.set(key, JSON.stringify(value)); }, removeItem: (key) => { - storage.delete(key); + storage.remove(key); }, }, ); @@ -108,7 +108,7 @@ export const sortOrderPreferenceAtom = atomWithStorage( storage.set(key, JSON.stringify(value)); }, removeItem: (key) => { - storage.delete(key); + storage.remove(key); }, }, ); diff --git a/utils/atoms/orientation.ts b/utils/atoms/orientation.ts deleted file mode 100644 index ab4fbafd..00000000 --- a/utils/atoms/orientation.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { atom } from "jotai"; -import * as ScreenOrientation from "@/packages/expo-screen-orientation"; - -export const orientationAtom = atom( - ScreenOrientation.OrientationLock.PORTRAIT_UP, -); diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts index 3b787e22..fccfbef6 100644 --- a/utils/atoms/queue.ts +++ b/utils/atoms/queue.ts @@ -3,7 +3,6 @@ import { atom, useAtom } from "jotai"; import { useEffect } from "react"; import { processesAtom } from "@/providers/DownloadProvider"; import { JobStatus } from "@/providers/Downloads/types"; -import { useSettings } from "@/utils/atoms/settings"; export interface Job { id: string; @@ -52,21 +51,21 @@ export const queueActions = { }, }; +const DEFAULT_CONCURRENT_LIMIT = 2; + export const useJobProcessor = () => { const [queue, setQueue] = useAtom(queueAtom); const [running, setRunning] = useAtom(runningAtom); const [processes] = useAtom(processesAtom); - const { settings } = useSettings(); useEffect(() => { if ( !running && queue.length > 0 && - settings && - processes.length < settings?.remuxConcurrentLimit + processes.length < DEFAULT_CONCURRENT_LIMIT ) { console.info("Processing queue", queue); queueActions.processJob(queue, setQueue, setRunning); } - }, [processes, queue, running, setQueue, setRunning, settings]); + }, [processes, queue, running, setQueue, setRunning]); }; diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 376b360f..ac49a19f 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -79,10 +79,6 @@ export type DefaultLanguageOption = { label: string; }; -export enum DownloadMethod { - Remux = "remux", -} - export type Home = { sections: Array; }; @@ -142,8 +138,6 @@ export enum VideoPlayer { export type Settings = { home?: Home | null; - followDeviceOrientation?: boolean; - forceLandscapeInVideoPlayer?: boolean; deviceProfile?: "Expo" | "Native" | "Old"; mediaListCollectionIds?: string[]; preferedLanguage?: string; @@ -163,12 +157,9 @@ export type Settings = { defaultVideoOrientation: ScreenOrientation.OrientationLock; forwardSkipTime: number; rewindSkipTime: number; - downloadMethod: DownloadMethod; - autoDownload: boolean; showCustomMenuLinks: boolean; disableHapticFeedback: boolean; subtitleSize: number; - remuxConcurrentLimit: 1 | 2 | 3 | 4; safeAreaInControlsEnabled: boolean; jellyseerrServerUrl?: string; hiddenLibraries?: string[]; @@ -205,8 +196,6 @@ export type StreamyfinPluginConfig = { export const defaultValues: Settings = { home: null, - followDeviceOrientation: true, - forceLandscapeInVideoPlayer: false, deviceProfile: "Expo", mediaListCollectionIds: [], preferedLanguage: undefined, @@ -232,12 +221,9 @@ export const defaultValues: Settings = { defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT, forwardSkipTime: 30, rewindSkipTime: 10, - downloadMethod: DownloadMethod.Remux, - autoDownload: false, showCustomMenuLinks: false, disableHapticFeedback: false, subtitleSize: Platform.OS === "ios" ? 60 : 100, - remuxConcurrentLimit: 1, safeAreaInControlsEnabled: true, jellyseerrServerUrl: undefined, hiddenLibraries: [], @@ -257,7 +243,7 @@ export const defaultValues: Settings = { enableLeftSideBrightnessSwipe: true, enableRightSideVolumeSwipe: true, usePopularPlugin: true, - showLargeHomeCarousel: true, + showLargeHomeCarousel: false, }; const loadSettings = (): Partial => { diff --git a/utils/download.ts b/utils/download.ts index 8be00f50..411e3f9a 100644 --- a/utils/download.ts +++ b/utils/download.ts @@ -10,20 +10,15 @@ const useDownloadHelper = () => { const { saveImage } = useImageStorage(); const saveSeriesPrimaryImage = async (item: BaseItemDto) => { - console.log(`Attempting to save primary image for item: ${item.Id}`); if ( item.Type === "Episode" && item.SeriesId && !storage.getString(item.SeriesId) ) { - console.log(`Saving primary image for series: ${item.SeriesId}`); await saveImage( item.SeriesId, getPrimaryImageUrlById({ api, id: item.SeriesId }), ); - console.log(`Primary image saved for series: ${item.SeriesId}`); - } else { - console.log(`Skipping primary image save for item: ${item.Id}`); } }; @@ -31,3 +26,28 @@ const useDownloadHelper = () => { }; export default useDownloadHelper; + +/** + * Estimates the download file size based on bitrate and video duration. + * Used when transcoding at lower bitrates where final size is unknown. + * Adds 10% overhead to account for container and metadata. + * + * @param bitrateValue - The bitrate in bits per second + * @param runTimeTicks - The video duration in ticks (1 tick = 100 nanoseconds) + * @returns Estimated file size in bytes (with 10% overhead), or undefined if duration is invalid + */ +export function estimateDownloadSize( + bitrateValue: number, + runTimeTicks?: number | null, +): number | undefined { + if (!runTimeTicks || runTimeTicks <= 0) return undefined; + + // Convert ticks to seconds (1 tick = 100 nanoseconds) + const durationSeconds = runTimeTicks / 10000000; + + // Calculate size in bytes: (bitrate * duration) / 8 + // Add 10% overhead for container and metadata + const estimatedBytes = ((bitrateValue * durationSeconds) / 8) * 1.1; + + return Math.floor(estimatedBytes); +} diff --git a/utils/log.tsx b/utils/log.tsx index 956f1fb0..e892b177 100644 --- a/utils/log.tsx +++ b/utils/log.tsx @@ -16,7 +16,7 @@ interface LogEntry { const mmkvStorage = createJSONStorage(() => ({ getItem: (key: string) => storage.getString(key) || null, setItem: (key: string, value: string) => storage.set(key, value), - removeItem: (key: string) => storage.delete(key), + removeItem: (key: string) => storage.remove(key), })); const logsAtom = atomWithStorage("logs", [], mmkvStorage); @@ -74,7 +74,7 @@ export const readFromLog = (): LogEntry[] => { }; export const clearLogs = () => { - storage.delete("logs"); + storage.remove("logs"); }; export const dumpDownloadDiagnostics = (extra: any = {}) => { diff --git a/utils/mmkv.ts b/utils/mmkv.ts index d89b3042..83cf9628 100644 --- a/utils/mmkv.ts +++ b/utils/mmkv.ts @@ -1,5 +1,5 @@ -import { MMKV } from "react-native-mmkv"; +import { createMMKV } from "react-native-mmkv"; // Create a single MMKV instance following the official documentation // https://github.com/mrousavy/react-native-mmkv -export const storage = new MMKV(); +export const storage = createMMKV(); diff --git a/utils/profiles/native.d.ts b/utils/profiles/native.d.ts new file mode 100644 index 00000000..fea7aa06 --- /dev/null +++ b/utils/profiles/native.d.ts @@ -0,0 +1,11 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +export interface DeviceProfileOptions { + transcode?: boolean; +} + +export function generateDeviceProfile(options?: DeviceProfileOptions): any; diff --git a/utils/segments.ts b/utils/segments.ts index 136eb98e..c55b1da5 100644 --- a/utils/segments.ts +++ b/utils/segments.ts @@ -4,6 +4,20 @@ import React from "react"; import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types"; import { getAuthHeaders } from "./jellyfin/jellyfin"; +// New Jellyfin 10.11+ Media Segments API types +interface MediaSegmentDto { + Id: string; + ItemId: string; + Type: "Intro" | "Outro" | "Recap" | "Commercial" | "Preview"; + StartTicks: number; + EndTicks: number; +} + +interface MediaSegmentsResponse { + Items: MediaSegmentDto[]; +} + +// Legacy API types (for fallback) interface IntroTimestamps { EpisodeId: string; HideSkipPromptAt: number; @@ -26,6 +40,8 @@ interface CreditTimestamps { }; } +const TICKS_PER_SECOND = 10000000; + export const useSegments = ( itemId: string, isOffline: boolean, @@ -65,7 +81,66 @@ export const getSegmentsForItem = ( }; }; -export const fetchAndParseSegments = async ( +/** + * Converts Jellyfin ticks to seconds + */ +const ticksToSeconds = (ticks: number): number => ticks / TICKS_PER_SECOND; + +/** + * Fetches segments using the new Jellyfin 10.11+ MediaSegments API + */ +const fetchMediaSegments = async ( + itemId: string, + api: Api, +): Promise<{ + introSegments: MediaTimeSegment[]; + creditSegments: MediaTimeSegment[]; +} | null> => { + try { + const response = await api.axiosInstance.get( + `${api.basePath}/MediaSegments/${itemId}`, + { + headers: getAuthHeaders(api), + params: { + includeSegmentTypes: ["Intro", "Outro"], + }, + }, + ); + + const introSegments: MediaTimeSegment[] = []; + const creditSegments: MediaTimeSegment[] = []; + + response.data.Items.forEach((segment) => { + const timeSegment: MediaTimeSegment = { + startTime: ticksToSeconds(segment.StartTicks), + endTime: ticksToSeconds(segment.EndTicks), + text: segment.Type, + }; + + switch (segment.Type) { + case "Intro": + introSegments.push(timeSegment); + break; + case "Outro": + creditSegments.push(timeSegment); + break; + // Optionally handle other types like Recap, Commercial, Preview + default: + break; + } + }); + + return { introSegments, creditSegments }; + } catch (_error) { + // Return null to indicate we should try legacy endpoints + return null; + } +}; + +/** + * Fetches segments using legacy pre-10.11 endpoints + */ +const fetchLegacySegments = async ( itemId: string, api: Api, ): Promise<{ @@ -106,8 +181,25 @@ export const fetchAndParseSegments = async ( }); } } catch (error) { - console.error("Failed to fetch segments", error); + console.error("Failed to fetch legacy segments", error); } return { introSegments, creditSegments }; }; + +export const fetchAndParseSegments = async ( + itemId: string, + api: Api, +): Promise<{ + introSegments: MediaTimeSegment[]; + creditSegments: MediaTimeSegment[]; +}> => { + // Try new API first (Jellyfin 10.11+) + const newSegments = await fetchMediaSegments(itemId, api); + if (newSegments) { + return newSegments; + } + + // Fallback to legacy endpoints + return fetchLegacySegments(itemId, api); +}; diff --git a/utils/time.ts b/utils/time.ts index 6648c668..87f4d285 100644 --- a/utils/time.ts +++ b/utils/time.ts @@ -72,7 +72,7 @@ export const formatTimeString = ( export const secondsToTicks = (seconds?: number | undefined) => { if (!seconds) return 0; - return seconds * 10000000; + return Math.floor(seconds * 10000000); }; export const ticksToSeconds = (ticks?: number | undefined) => { @@ -82,7 +82,7 @@ export const ticksToSeconds = (ticks?: number | undefined) => { export const msToTicks = (ms?: number | undefined) => { if (!ms) return 0; - return ms * 10000; + return Math.floor(ms * 10000); }; export const ticksToMs = (ticks?: number | undefined) => {