From ca92f61900bf0e40ce78e4c2b281880dfbc42bba Mon Sep 17 00:00:00 2001 From: Alex <111128610+Alexk2309@users.noreply.github.com> Date: Sat, 16 Aug 2025 05:34:22 +1000 Subject: [PATCH] refactor: Feature/offline mode rework (#859) Co-authored-by: lostb1t Co-authored-by: Fredrik Burmester Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com> Co-authored-by: Gauvino Co-authored-by: storm1er Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Chris <182387676+whoopsi-daisy@users.noreply.github.com> Co-authored-by: arch-fan <55891793+arch-fan@users.noreply.github.com> Co-authored-by: Alex Kim --- app/(auth)/(tabs)/(home)/_layout.tsx | 6 - .../(tabs)/(home)/downloads/[seriesId].tsx | 6 +- app/(auth)/(tabs)/(home)/downloads/index.tsx | 158 +- .../(tabs)/(home)/settings/logs/page.tsx | 9 +- .../(home)/settings/optimized-server/page.tsx | 93 -- .../collections/[collectionId].tsx | 2 +- .../items/page.tsx | 27 +- .../series/[id].tsx | 12 +- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 2 +- app/(auth)/(tabs)/(search)/index.tsx | 8 +- app/(auth)/player/direct-player.tsx | 145 +- app/_layout.tsx | 234 +-- bun.lock | 118 +- components/ContinueWatchingPoster.tsx | 31 +- components/DownloadItem.tsx | 153 +- components/ItemContent.tsx | 66 +- components/ItemHeader.tsx | 8 +- components/ItemTechnicalDetails.tsx | 1 + components/PlayButton.tsx | 6 +- components/PlayedStatus.tsx | 39 +- components/SubtitleTrackSelector.tsx | 8 +- components/common/JellyseerrItemRouter.tsx | 2 +- components/common/ProgressBar.tsx | 47 + components/common/TouchableItemRouter.tsx | 9 +- components/downloads/ActiveDownloads.tsx | 52 +- components/downloads/DownloadSize.tsx | 3 +- components/downloads/EpisodeCard.tsx | 30 +- components/downloads/MovieCard.tsx | 27 +- components/home/LargeMovieCarousel.tsx | 2 +- components/home/ScrollingCollectionList.tsx | 3 + components/jellyseerr/ParallaxSlideShow.tsx | 2 +- components/jellyseerr/discover/Slide.tsx | 2 +- components/search/SearchItemWrapper.tsx | 4 +- components/series/CastAndCrew.tsx | 2 +- components/series/EpisodeTitleHeader.tsx | 2 +- components/series/SeasonDropdown.tsx | 23 +- components/series/SeasonEpisodesCarousel.tsx | 44 +- components/series/SeasonPicker.tsx | 7 +- components/settings/DownloadSettings.tsx | 111 +- components/settings/HomeIndex.tsx | 17 +- components/settings/OptimizedServerForm.tsx | 45 - components/settings/StorageSettings.tsx | 17 +- components/video-player/controls/Controls.tsx | 66 +- .../video-player/controls/EpisodeList.tsx | 222 +-- .../controls/contexts/VideoContext.tsx | 57 +- .../controls/dropdown/DropdownView.tsx | 59 +- hooks/useAdjacentEpisodes.ts | 64 - hooks/useCreditSkipper.ts | 59 +- hooks/useDownloadedFileOpener.ts | 37 +- hooks/useIntroSkipper.ts | 54 +- hooks/useItemQuery.ts | 31 + hooks/useMarkAsPlayed.ts | 105 +- hooks/usePlaybackManager.ts | 284 ++++ hooks/useRevalidatePlaybackProgressCache.ts | 33 +- hooks/useTrickplay.ts | 270 ++-- hooks/useTwoWaySync.ts | 86 ++ modules/VlcPlayer.types.ts | 6 +- modules/VlcPlayerView.tsx | 17 +- modules/vlc-player-3/expo-module.config.json | 6 - modules/vlc-player-3/ios/VlcPlayer3View.swift | 392 ----- modules/vlc-player-4/expo-module.config.json | 7 + .../ios/AppLifecycleDelegate.swift | 2 +- .../ios/VLCManager.swift | 0 .../ios/VlcPlayer4.podspec} | 11 +- .../ios/VlcPlayer4Module.swift} | 30 +- modules/vlc-player-4/ios/VlcPlayer4View.swift | 507 +++++++ .../src/VlcPlayer4Module.ts} | 2 +- .../expo/modules/vlcplayer/VlcPlayerView.kt | 28 +- modules/vlc-player/expo-module.config.json | 3 +- modules/vlc-player/ios/VlcPlayer.podspec | 9 +- modules/vlc-player/ios/VlcPlayerModule.swift | 9 +- modules/vlc-player/ios/VlcPlayerView.swift | 481 +++---- plugins/withRNBackgroundDownloader.js | 6 +- providers/DownloadProvider.tsx | 1272 ++++++++--------- providers/Downloads/types.ts | 132 ++ providers/JellyfinProvider.tsx | 2 - providers/JobQueueProvider.tsx | 15 - providers/PlaySettingsProvider.tsx | 4 +- translations/en.json | 1 + utils/atoms/queue.ts | 4 +- utils/atoms/settings.ts | 3 - utils/eventBus.ts | 4 +- utils/jellyfin/getDefaultPlaySettings.ts | 11 +- utils/jellyfin/media/getDownloadUrl.ts | 68 + utils/jellyfin/media/getStreamUrl.ts | 146 +- utils/jellyfin/playstate/markAsNotPlayed.ts | 45 - utils/jellyfin/playstate/markAsPlayed.ts | 37 - .../playstate/reportPlaybackProgress.ts | 60 - utils/jellyfin/session/capabilities.ts | 2 +- utils/optimize-server.ts | 233 --- utils/profiles/download.js | 25 - utils/profiles/native.js | 88 +- utils/profiles/subtitles.js | 56 + utils/segments.ts | 114 ++ 94 files changed, 3325 insertions(+), 3523 deletions(-) delete mode 100644 app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx create mode 100644 components/common/ProgressBar.tsx delete mode 100644 components/settings/OptimizedServerForm.tsx delete mode 100644 hooks/useAdjacentEpisodes.ts create mode 100644 hooks/useItemQuery.ts create mode 100644 hooks/usePlaybackManager.ts create mode 100644 hooks/useTwoWaySync.ts delete mode 100644 modules/vlc-player-3/expo-module.config.json delete mode 100644 modules/vlc-player-3/ios/VlcPlayer3View.swift create mode 100644 modules/vlc-player-4/expo-module.config.json rename modules/{vlc-player => vlc-player-4}/ios/AppLifecycleDelegate.swift (99%) rename modules/{vlc-player => vlc-player-4}/ios/VLCManager.swift (100%) rename modules/{vlc-player-3/ios/VlcPlayer3.podspec => vlc-player-4/ios/VlcPlayer4.podspec} (70%) rename modules/{vlc-player-3/ios/VlcPlayer3Module.swift => vlc-player-4/ios/VlcPlayer4Module.swift} (66%) create mode 100644 modules/vlc-player-4/ios/VlcPlayer4View.swift rename modules/{vlc-player-3/src/VlcPlayer3Module.ts => vlc-player-4/src/VlcPlayer4Module.ts} (80%) create mode 100644 providers/Downloads/types.ts delete mode 100644 providers/JobQueueProvider.tsx create mode 100644 utils/jellyfin/media/getDownloadUrl.ts delete mode 100644 utils/jellyfin/playstate/markAsNotPlayed.ts delete mode 100644 utils/jellyfin/playstate/markAsPlayed.ts delete mode 100644 utils/jellyfin/playstate/reportPlaybackProgress.ts delete mode 100644 utils/optimize-server.ts create mode 100644 utils/profiles/subtitles.js create mode 100644 utils/segments.ts diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 1165716d..5ad41f97 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -66,12 +66,6 @@ export default function IndexLayout() { title: t("home.settings.settings_title"), }} /> - ( {}, ); - const { downloadedFiles, deleteItems } = useDownload(); + const { getDownloadedItems, deleteItems } = useDownload(); const series = useMemo(() => { try { return ( - downloadedFiles + getDownloadedItems() ?.filter((f) => f.item.SeriesId === seriesId) ?.sort( (a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!, @@ -37,7 +37,7 @@ export default function page() { } catch { return []; } - }, [downloadedFiles]); + }, [getDownloadedItems]); const seasonIndex = seasonIndexState[series?.[0]?.item?.ParentId ?? ""] || diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 1f2cedb9..ae5c32f5 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -10,33 +10,34 @@ import { useAtom } from "jotai"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { toast } from "sonner-native"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; +import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { ActiveDownloads } from "@/components/downloads/ActiveDownloads"; import { DownloadSize } from "@/components/downloads/DownloadSize"; import { MovieCard } from "@/components/downloads/MovieCard"; import { SeriesCard } from "@/components/downloads/SeriesCard"; -import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider"; +import { useDownload } from "@/providers/DownloadProvider"; +import { type DownloadedItem } from "@/providers/Downloads/types"; import { queueAtom } from "@/utils/atoms/queue"; -import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; import { writeToLog } from "@/utils/log"; export default function page() { const navigation = useNavigation(); const { t } = useTranslation(); const [queue, setQueue] = useAtom(queueAtom); - const { removeProcess, downloadedFiles, deleteFileByType, deleteAllFiles } = - useDownload(); + const { + removeProcess, + getDownloadedItems, + deleteFileByType, + deleteAllFiles, + } = useDownload(); const router = useRouter(); - const [settings] = useSettings(); const bottomSheetModalRef = useRef(null); const [showMigration, setShowMigration] = useState(false); - const insets = useSafeAreaInsets(); - const migration_20241124 = () => { Alert.alert( t("home.downloads.new_app_version_requires_re_download"), @@ -44,7 +45,10 @@ export default function page() { [ { text: t("home.downloads.back"), - onPress: () => setShowMigration(false) || router.back(), + onPress: () => { + setShowMigration(false); + router.back(); + }, }, { text: t("home.downloads.delete"), @@ -58,6 +62,8 @@ export default function page() { ); }; + const downloadedFiles = getDownloadedItems(); + const movies = useMemo(() => { try { return downloadedFiles?.filter((f) => f.item.Type === "Movie") || []; @@ -127,16 +133,10 @@ export default function page() { return ( <> - - - - {settings?.downloadMethod === DownloadMethod.Remux && ( + + + + {t("home.downloads.queue")} @@ -180,70 +180,74 @@ export default function page() { )} - )} - - - - {movies.length > 0 && ( - - - - {t("home.downloads.movies")} - - - {movies?.length} - - - - - {movies?.map((item) => ( - - - - ))} - - + - )} - {groupedBySeries.length > 0 && ( - - - - {t("home.downloads.tvseries")} - - - - {groupedBySeries?.length} + + {movies.length > 0 && ( + + + + {t("home.downloads.movies")} + + {movies?.length} + + + + {movies?.map((item) => ( + + + + ))} + + - - - {groupedBySeries?.map((items) => ( - - i.item)} - key={items[0].item.SeriesId} - /> - - ))} + )} + {groupedBySeries.length > 0 && ( + + + + {t("home.downloads.tvseries")} + + + + {groupedBySeries?.length} + + - - - )} - {downloadedFiles?.length === 0 && ( - - - {t("home.downloads.no_downloaded_items")} - - - )} - - + + + {groupedBySeries?.map((items) => ( + + i.item)} + key={items[0].item.SeriesId} + /> + + ))} + + + + )} + {downloadedFiles?.length === 0 && ( + + + {t("home.downloads.no_downloaded_items")} + + + )} + + + ["asc", "desc"]} set={(values) => setOrder(values[0])} @@ -83,7 +86,7 @@ export default function page() { showSearch={false} /> defaultLevels} set={setLevels} diff --git a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx deleted file mode 100644 index aff5f3ca..00000000 --- a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { useNavigation } from "expo-router"; -import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { ActivityIndicator, TouchableOpacity } from "react-native"; -import { toast } from "sonner-native"; -import { Text } from "@/components/common/Text"; -import DisabledSetting from "@/components/settings/DisabledSetting"; -import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { getOrSetDeviceId } from "@/utils/device"; -import { getStatistics } from "@/utils/optimize-server"; - -export default function page() { - const navigation = useNavigation(); - - const { t } = useTranslation(); - - const [api] = useAtom(apiAtom); - const [settings, updateSettings, pluginSettings] = useSettings(); - - const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = - useState(settings?.optimizedVersionsServerUrl || ""); - - const saveMutation = useMutation({ - mutationFn: async (newVal: string) => { - if (newVal.length === 0 || !newVal.startsWith("http")) { - toast.error(t("home.settings.toasts.invalid_url")); - return; - } - - const updatedUrl = newVal.endsWith("/") ? newVal : `${newVal}/`; - - updateSettings({ - optimizedVersionsServerUrl: updatedUrl, - }); - - return await getStatistics({ - url: updatedUrl, - authHeader: api?.accessToken, - deviceId: getOrSetDeviceId(), - }); - }, - onSuccess: (data) => { - if (data) { - toast.success(t("home.settings.toasts.connected")); - } else { - toast.error(t("home.settings.toasts.could_not_connect")); - } - }, - onError: () => { - toast.error(t("home.settings.toasts.could_not_connect")); - }, - }); - - const onSave = (newVal: string) => { - saveMutation.mutate(newVal); - }; - - useEffect(() => { - if (!pluginSettings?.optimizedVersionsServerUrl?.locked) { - navigation.setOptions({ - title: t("home.settings.downloads.optimized_server"), - headerRight: () => - saveMutation.isPending ? ( - - ) : ( - onSave(optimizedVersionsServerUrl)} - > - - {t("home.settings.downloads.save_button")} - - - ), - }); - } - }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]); - - return ( - - - - ); -} 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 9260ccc8..798fa2cc 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx @@ -112,7 +112,7 @@ const page: React.FC = () => { recursive: true, genres: selectedGenres, tags: selectedTags, - years: selectedYears.map((year) => Number.parseInt(year)), + years: selectedYears.map((year) => Number.parseInt(year, 10)), includeItemTypes: ["Movie", "Series"], }); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx index d55c05d4..81f123bf 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx @@ -1,7 +1,4 @@ -import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; import { useLocalSearchParams } from "expo-router"; -import { useAtom } from "jotai"; import type React from "react"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; @@ -14,30 +11,16 @@ import Animated, { } from "react-native-reanimated"; import { Text } from "@/components/common/Text"; import { ItemContent } from "@/components/ItemContent"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useItemQuery } from "@/hooks/useItemQuery"; const Page: React.FC = () => { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); const { id } = useLocalSearchParams() as { id: string }; const { t } = useTranslation(); - const { data: item, isError } = useQuery({ - queryKey: ["item", id], - queryFn: async () => { - if (!api || !user || !id) return; - const res = await getUserLibraryApi(api).getItem({ - itemId: id, - userId: user?.Id, - }); + const { offline } = useLocalSearchParams() as { offline?: string }; + const isOffline = offline === "true"; - return res.data; - }, - staleTime: 0, - refetchOnMount: true, - refetchOnWindowFocus: true, - refetchOnReconnect: true, - }); + const { data: item, isError } = useItemQuery(id, isOffline); const opacity = useSharedValue(1); const animatedStyle = useAnimatedStyle(() => { @@ -107,7 +90,7 @@ const Page: React.FC = () => { - {item && } + {item && } ); }; 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 17c8cf69..bf22d464 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx @@ -69,10 +69,18 @@ const page: React.FC = () => { seriesId: item?.Id!, userId: user?.Id!, enableUserData: true, - fields: ["MediaSources", "MediaStreams", "Overview"], + // Note: Including trick play is necessary to enable trick play downloads + fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], }); return res?.data.Items || []; }, + select: (data) => + // This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order. + [...(data || [])].sort( + (a, b) => + (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || + (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0), + ), staleTime: 60, enabled: !!api && !!user?.Id && !!item?.Id, }); @@ -136,7 +144,7 @@ const page: React.FC = () => { resizeMode: "contain", }} /> - ) : null + ) : undefined } > diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 6800eb31..2f58d74c 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -168,7 +168,7 @@ const Page = () => { fields: ["PrimaryImageAspectRatio", "SortName"], genres: selectedGenres, tags: selectedTags, - years: selectedYears.map((year) => Number.parseInt(year)), + years: selectedYears.map((year) => Number.parseInt(year, 10)), includeItemTypes: itemType ? [itemType] : undefined, }); diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 765aa2e0..7227b5b4 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -10,6 +10,7 @@ import { useAtom } from "jotai"; import { useCallback, useEffect, + useId, useLayoutEffect, useMemo, useRef, @@ -58,6 +59,9 @@ export default function search() { const { t } = useTranslation(); + const searchFilterId = useId(); + const orderFilterId = useId(); + const { q } = params as { q: string }; const [searchType, setSearchType] = useState("Library"); @@ -313,7 +317,7 @@ export default function search() { debouncedSearch.length > 0 && ( Object.keys(JellyseerrSearchSort).filter((v) => @@ -329,7 +333,7 @@ export default function search() { showSearch={false} /> ["asc", "desc"]} set={(value) => setJellyseerrSortOrder(value[0])} diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 3f441b75..c7cff282 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -2,7 +2,6 @@ import { type BaseItemDto, type MediaSourceInfo, PlaybackOrder, - type PlaybackProgressInfo, PlaybackStartInfo, RepeatMode, } from "@jellyfin/sdk/lib/generated-client"; @@ -22,8 +21,8 @@ import { BITRATES } from "@/components/BitrateSelector"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import { Controls } from "@/components/video-player/controls/Controls"; -import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; import { useHaptic } from "@/hooks/useHaptic"; +import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useWebSocket } from "@/hooks/useWebsockets"; import { VlcPlayerView } from "@/modules"; @@ -33,18 +32,16 @@ import type { ProgressUpdatePayload, VlcPlayerViewRef, } from "@/modules/VlcPlayer.types"; +import { useDownload } from "@/providers/DownloadProvider"; +import { DownloadedItem } from "@/providers/Downloads/types"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { writeToLog } from "@/utils/log"; import { storage } from "@/utils/mmkv"; -import generateDeviceProfile from "@/utils/profiles/native"; +import { generateDeviceProfile } from "@/utils/profiles/native"; import { msToTicks, ticksToSeconds } from "@/utils/time"; -const downloadProvider = !Platform.isTV - ? require("@/providers/DownloadProvider") - : { useDownload: () => null }; - const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas"; export default function page() { @@ -74,7 +71,7 @@ export default function page() { ? null : require("react-native-volume-manager"); - const getDownloadedItem = downloadProvider.useDownload(); + const downloadUtils = useDownload(); const revalidateProgressCache = useInvalidatePlaybackProgressCache(); @@ -111,6 +108,7 @@ export default function page() { const [settings] = useSettings(); const insets = useSafeAreaInsets(); const offline = offlineStr === "true"; + const playbackManager = usePlaybackManager(); const audioIndex = audioIndexStr ? Number.parseInt(audioIndexStr, 10) @@ -123,18 +121,21 @@ export default function page() { : BITRATES[0].value; const [item, setItem] = useState(null); + const [downloadedItem, setDownloadedItem] = useState( + null, + ); const [itemStatus, setItemStatus] = useState({ isLoading: true, isError: false, }); - /** Gets the initial playback position from the URL or the item's user data. */ + /** Gets the initial playback position from the URL. */ const getInitialPlaybackTicks = useCallback((): number => { if (playbackPositionFromUrl) { return Number.parseInt(playbackPositionFromUrl, 10); } return item?.UserData?.PlaybackPositionTicks ?? 0; - }, [playbackPositionFromUrl, item]); + }, [playbackPositionFromUrl]); useEffect(() => { const fetchItemData = async () => { @@ -142,8 +143,11 @@ export default function page() { try { let fetchedItem: BaseItemDto | null = null; if (offline && !Platform.isTV) { - const data = await getDownloadedItem.getDownloadedItem(itemId); - if (data) fetchedItem = data.item as BaseItemDto; + const data = downloadUtils.getDownloadedItemById(itemId); + if (data) { + fetchedItem = data.item as BaseItemDto; + setDownloadedItem(data); + } } else { const res = await getUserLibraryApi(api!).getItem({ itemId, @@ -179,18 +183,20 @@ export default function page() { useEffect(() => { const fetchStreamData = async () => { setStreamStatus({ isLoading: true, isError: false }); - const native = await generateDeviceProfile(); try { let result: Stream | null = null; - if (offline && !Platform.isTV) { - const data = await getDownloadedItem.getDownloadedItem(itemId); - if (!data?.mediaSource) return; - const url = await getDownloadedFileUrl(data.item.Id!); + if (offline && downloadedItem && downloadedItem.mediaSource) { + const url = downloadedItem.videoFilePath; if (item) { - result = { mediaSource: data.mediaSource, sessionId: "", url }; + result = { + mediaSource: downloadedItem.mediaSource, + sessionId: "", + url: url, + }; } } else { - if (!item) return; + const native = generateDeviceProfile(); + const transcoding = generateDeviceProfile({ transcode: true }); const res = await getStreamUrl({ api, item, @@ -200,7 +206,7 @@ export default function page() { maxStreamingBitrate: bitrateValue, mediaSourceId: mediaSourceId, subtitleStreamIndex: subtitleIndex, - deviceProfile: native, + deviceProfile: bitrateValue ? transcoding : native, }); if (!res) return; const { mediaSource, sessionId, url } = res; @@ -221,26 +227,39 @@ export default function page() { } }; fetchStreamData(); - }, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]); + }, [ + itemId, + mediaSourceId, + bitrateValue, + api, + item, + user?.Id, + downloadedItem, + ]); useEffect(() => { - if (!stream) return; - + if (!stream || !api) return; const reportPlaybackStart = async () => { - await getPlaystateApi(api!).reportPlaybackStart({ + await getPlaystateApi(api).reportPlaybackStart({ playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo, }); }; - reportPlaybackStart(); - }, [stream]); + }, [stream, api]); const togglePlay = async () => { lightHapticFeedback(); setIsPlaying(!isPlaying); if (isPlaying) { await videoRef.current?.pause(); - reportPlaybackProgress(); + playbackManager.reportPlaybackProgress( + item?.Id!, + msToTicks(progress.get()), + { + AudioStreamIndex: audioIndex ?? -1, + SubtitleStreamIndex: subtitleIndex ?? -1, + }, + ); } else { videoRef.current?.play(); await getPlaystateApi(api!).reportPlaybackStart({ @@ -250,7 +269,6 @@ export default function page() { }; const reportPlaybackStopped = useCallback(async () => { - if (offline) return; const currentTimeInTicks = msToTicks(progress.get()); await getPlaystateApi(api!).onPlaybackStopped({ itemId: item?.Id!, @@ -258,8 +276,6 @@ export default function page() { positionTicks: currentTimeInTicks, playSessionId: stream?.sessionId!, }); - - revalidateProgressCache(); }, [ api, item, @@ -274,6 +290,7 @@ export default function page() { reportPlaybackStopped(); setIsPlaybackStopped(true); videoRef.current?.stop(); + revalidateProgressCache(); }, [videoRef, reportPlaybackStopped]); useEffect(() => { @@ -317,10 +334,16 @@ export default function page() { playbackPosition: msToTicks(currentTime).toString(), }); - if (offline) return; - if (!item?.Id || !stream) return; + if (!item?.Id) return; - reportPlaybackProgress(); + playbackManager.reportPlaybackProgress( + item.Id, + msToTicks(progress.get()), + { + AudioStreamIndex: audioIndex ?? -1, + SubtitleStreamIndex: subtitleIndex ?? -1, + }, + ); }, [ item?.Id, @@ -340,28 +363,10 @@ export default function page() { setIsPipStarted(pipStarted); }, []); - const reportPlaybackProgress = useCallback(async () => { - if (!api || offline || !stream) return; - await getPlaystateApi(api).reportPlaybackProgress({ - playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo, - }); - }, [ - api, - isPlaying, - offline, - stream, - item?.Id, - audioIndex, - subtitleIndex, - mediaSourceId, - progress, - ]); - /** Gets the initial playback position in seconds. */ const startPosition = useMemo(() => { - if (offline) return 0; return ticksToSeconds(getInitialPlaybackTicks()); - }, [offline, getInitialPlaybackTicks]); + }, [getInitialPlaybackTicks]); const volumeUpCb = useCallback(async () => { if (Platform.isTV) return; @@ -446,14 +451,28 @@ export default function page() { const { state, isBuffering, isPlaying } = e.nativeEvent; if (state === "Playing") { setIsPlaying(true); - reportPlaybackProgress(); + if (item?.Id) { + playbackManager.reportPlaybackProgress( + item.Id, + msToTicks(progress.get()), + { + AudioStreamIndex: audioIndex ?? -1, + SubtitleStreamIndex: subtitleIndex ?? -1, + }, + ); + } if (!Platform.isTV) await activateKeepAwakeAsync(); return; } if (state === "Paused") { setIsPlaying(false); - reportPlaybackProgress(); + if (item?.Id) { + playbackManager.reportPlaybackProgress( + item.Id, + msToTicks(progress.get()), + ); + } if (!Platform.isTV) await deactivateKeepAwake(); return; } @@ -465,7 +484,7 @@ export default function page() { setIsBuffering(true); } }, - [reportPlaybackProgress], + [playbackManager, item?.Id, progress], ); const allAudio = @@ -483,25 +502,29 @@ export default function page() { .filter((sub: any) => sub.DeliveryMethod === "External") .map((sub: any) => ({ name: sub.DisplayTitle, - DeliveryUrl: api?.basePath + sub.DeliveryUrl, + DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl, })); - + /** The text based subtitle tracks */ const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream); - + /** The user chosen subtitle track from the server */ const chosenSubtitleTrack = allSubs.find( (sub) => sub.Index === subtitleIndex, ); + /** The user chosen audio track from the server */ const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex); - + /** Whether the stream we're playing is not transcoding*/ const notTranscoding = !stream?.mediaSource.TranscodingUrl; + /** The initial options to pass to the VLC Player */ const initOptions = [`--sub-text-scale=${settings.subtitleSize}`]; if ( chosenSubtitleTrack && (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream) ) { + // If not transcoding, we can the index as normal. + // If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles. const finalIndex = notTranscoding ? allSubs.indexOf(chosenSubtitleTrack) - : textSubs.indexOf(chosenSubtitleTrack); + : [...textSubs].reverse().indexOf(chosenSubtitleTrack); initOptions.push(`--sub-track=${finalIndex}`); } @@ -562,7 +585,7 @@ export default function page() { source={{ uri: stream?.url || "", autoplay: true, - isNetwork: true, + isNetwork: !offline, startPosition, externalSubtitles, initOptions, diff --git a/app/_layout.tsx b/app/_layout.tsx index 14474d63..dcc0583b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,7 +1,6 @@ import "@/augmentations"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { Platform } from "react-native"; import i18n from "@/i18n"; import { DownloadProvider } from "@/providers/DownloadProvider"; @@ -11,7 +10,6 @@ import { getTokenFromStorage, JellyfinProvider, } from "@/providers/JellyfinProvider"; -import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { type Settings, useSettings } from "@/utils/atoms/settings"; @@ -27,7 +25,6 @@ import { writeToLog, } from "@/utils/log"; import { storage } from "@/utils/mmkv"; -import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server"; const BackGroundDownloader = !Platform.isTV ? require("@kesha-antonov/react-native-background-downloader") @@ -145,100 +142,24 @@ if (!Platform.isTV) { TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { console.log("TaskManager ~ trigger"); - const now = Date.now(); + const settingsData = storage.getString("settings"); - try { - const settingsData = storage.getString("settings"); + if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; - if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; + const settings: Partial = JSON.parse(settingsData); - const settings: Partial = JSON.parse(settingsData); - const url = settings?.optimizedVersionsServerUrl; + if (!settings?.autoDownload) + return BackgroundFetch.BackgroundFetchResult.NoData; - if (!settings?.autoDownload || !url) - return BackgroundFetch.BackgroundFetchResult.NoData; + const token = getTokenFromStorage(); + const deviceId = getOrSetDeviceId(); + const baseDirectory = FileSystem.documentDirectory; - const token = getTokenFromStorage(); - const deviceId = getOrSetDeviceId(); - const baseDirectory = FileSystem.documentDirectory; + if (!token || !deviceId || !baseDirectory) + return BackgroundFetch.BackgroundFetchResult.NoData; - if (!token || !deviceId || !baseDirectory) - return BackgroundFetch.BackgroundFetchResult.NoData; - - const jobs = await getAllJobsByDeviceId({ - deviceId, - authHeader: token, - url, - }); - - console.log("TaskManager ~ Active jobs: ", jobs.length); - - for (const job of jobs) { - if (job.status === "completed") { - const downloadUrl = `${url}download/${job.id}`; - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - - if (tasks.find((task: { id: string }) => task.id === job.id)) { - console.log("TaskManager ~ Download already in progress: ", job.id); - continue; - } - - BackGroundDownloader.download({ - id: job.id, - url: downloadUrl, - destination: `${baseDirectory}${job.item.Id}.mp4`, - headers: { - Authorization: token, - }, - }) - .begin(() => { - console.log("TaskManager ~ Download started: ", job.id); - }) - .done(() => { - console.log("TaskManager ~ Download completed: ", job.id); - saveDownloadedItemInfo(job.item); - BackGroundDownloader.completeHandler(job.id); - cancelJobById({ - authHeader: token, - id: job.id, - url: url, - }); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: "Download completed", - data: { - url: "/downloads", - }, - }, - trigger: null, - }); - }) - .error((error: any) => { - console.log("TaskManager ~ Download error: ", job.id, error); - BackGroundDownloader.completeHandler(job.id); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: "Download failed", - data: { - url: "/downloads", - }, - }, - trigger: null, - }); - }); - } - } - - console.log(`Auto download started: ${new Date(now).toISOString()}`); - - // Be sure to return the successful result type! - return BackgroundFetch.BackgroundFetchResult.NewData; - } catch (error) { - console.error("Background task error:", error); - return BackgroundFetch.BackgroundFetchResult.Failed; - } + // Be sure to return the successful result type! + return BackgroundFetch.BackgroundFetchResult.NewData; }); } @@ -474,85 +395,62 @@ function Layout() { return ( - - - - - - - - - + null, + }} + /> + + + + + + + + + + + ); } - -function saveDownloadedItemInfo(item: BaseItemDto) { - try { - const downloadedItems = storage.getString("downloadedItems"); - const items: BaseItemDto[] = downloadedItems - ? JSON.parse(downloadedItems) - : []; - - const existingItemIndex = items.findIndex((i) => i.Id === item.Id); - if (existingItemIndex !== -1) { - items[existingItemIndex] = item; - } else { - items.push(item); - } - - storage.set("downloadedItems", JSON.stringify(items)); - } catch (error) { - writeToLog("ERROR", "Failed to save downloaded item information:", error); - console.error("Failed to save downloaded item information:", error); - } -} diff --git a/bun.lock b/bun.lock index 83323e93..627a1675 100644 --- a/bun.lock +++ b/bun.lock @@ -115,15 +115,15 @@ "@babel/compat-data": ["@babel/compat-data@7.28.0", "", {}, "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw=="], - "@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], + "@babel/core": ["@babel/core@7.28.3", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.3", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.2", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ=="], - "@babel/generator": ["@babel/generator@7.28.0", "", { "dependencies": { "@babel/parser": "^7.28.0", "@babel/types": "^7.28.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg=="], + "@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="], "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], - "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-member-expression-to-functions": "^7.27.1", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.27.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A=="], + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.3", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.27.1", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.3", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg=="], "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "regexpu-core": "^6.2.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ=="], @@ -135,7 +135,7 @@ "@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.27.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.27.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="], "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], @@ -153,13 +153,13 @@ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.27.1", "", { "dependencies": { "@babel/template": "^7.27.1", "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ=="], + "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.28.3", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.2" } }, "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g=="], - "@babel/helpers": ["@babel/helpers@7.28.2", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.2" } }, "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw=="], + "@babel/helpers": ["@babel/helpers@7.28.3", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.2" } }, "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw=="], "@babel/highlight": ["@babel/highlight@7.25.9", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "chalk": "^2.4.2", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw=="], - "@babel/parser": ["@babel/parser@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="], + "@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="], "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.28.0", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-decorators": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg=="], @@ -217,7 +217,7 @@ "@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-classes": ["@babel/plugin-transform-classes@7.28.0", "", { "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.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA=="], + "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.3", "", { "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.3" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-DoEWC5SuxuARF2KdKmGUq3ghfPMO6ZzR12Dnp5gubwbeWJo4dbNWXJPVlwvh4Zlq6Z7YVvL8VFxeSOJgjsx4Sg=="], "@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=="], @@ -267,9 +267,9 @@ "@babel/plugin-transform-react-pure-annotations": ["@babel/plugin-transform-react-pure-annotations@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA=="], - "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.28.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-P0QiV/taaa3kXpLY+sXla5zec4E+4t4Aqc9ggHlfZ7a2cp8/x/Gv08jfwEtn9gnnYIMvHx6aoOZ8XJL8eU71Dg=="], + "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.28.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-K3/M/a4+ESb5LEldjQb+XSrpY0nF+ZBFlTCbSnKaYAMfD8v33O6PMs4uYnOk19HlcsI8WMu3McdFPTiQHF/1/A=="], - "@babel/plugin-transform-runtime": ["@babel/plugin-transform-runtime@7.28.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-dGopk9nZrtCs2+nfIem25UuHyt5moSJamArzIoh9/vezUQPmYDOzjaHDCkAzuGJibCIkPup8rMT2+wYB6S73cA=="], + "@babel/plugin-transform-runtime": ["@babel/plugin-transform-runtime@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Y6ab1kGqZ0u42Zv/4a7l0l72n9DKP/MKoKWaUSBylrhNZO2prYuqFOLbn5aW5SIFXwSH93yfjbgllL8lxuGKLg=="], "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ=="], @@ -287,33 +287,33 @@ "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], - "@babel/runtime": ["@babel/runtime@7.28.2", "", {}, "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA=="], + "@babel/runtime": ["@babel/runtime@7.28.3", "", {}, "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA=="], "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], - "@babel/traverse": ["@babel/traverse@7.28.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/types": "^7.28.0", "debug": "^4.3.1" } }, "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg=="], + "@babel/traverse": ["@babel/traverse@7.28.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/types": "^7.28.2", "debug": "^4.3.1" } }, "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ=="], - "@babel/traverse--for-generate-function-map": ["@babel/traverse@7.28.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/types": "^7.28.0", "debug": "^4.3.1" } }, "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg=="], + "@babel/traverse--for-generate-function-map": ["@babel/traverse@7.28.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/types": "^7.28.2", "debug": "^4.3.1" } }, "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ=="], "@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], - "@biomejs/biome": ["@biomejs/biome@2.1.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.1.4", "@biomejs/cli-darwin-x64": "2.1.4", "@biomejs/cli-linux-arm64": "2.1.4", "@biomejs/cli-linux-arm64-musl": "2.1.4", "@biomejs/cli-linux-x64": "2.1.4", "@biomejs/cli-linux-x64-musl": "2.1.4", "@biomejs/cli-win32-arm64": "2.1.4", "@biomejs/cli-win32-x64": "2.1.4" }, "bin": { "biome": "bin/biome" } }, "sha512-QWlrqyxsU0FCebuMnkvBIkxvPqH89afiJzjMl+z67ybutse590jgeaFdDurE9XYtzpjRGTI1tlUZPGWmbKsElA=="], + "@biomejs/biome": ["@biomejs/biome@2.2.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.0", "@biomejs/cli-darwin-x64": "2.2.0", "@biomejs/cli-linux-arm64": "2.2.0", "@biomejs/cli-linux-arm64-musl": "2.2.0", "@biomejs/cli-linux-x64": "2.2.0", "@biomejs/cli-linux-x64-musl": "2.2.0", "@biomejs/cli-win32-arm64": "2.2.0", "@biomejs/cli-win32-x64": "2.2.0" }, "bin": { "biome": "bin/biome" } }, "sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sCrNENE74I9MV090Wq/9Dg7EhPudx3+5OiSoQOkIe3DLPzFARuL1dOwCWhKCpA3I5RHmbrsbNSRfZwCabwd8Qg=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-gOEICJbTCy6iruBywBDcG4X5rHMbqCPs3clh3UQ+hRKlgvJTk4NHWQAyHOXvaLe+AxD1/TNX1jbZeffBJzcrOw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-juhEkdkKR4nbUi5k/KRp1ocGPNWLgFRD4NrHZSveYrD6i98pyvuzmS9yFYgOZa5JhaVqo0HPnci0+YuzSwT2fw=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-nYr7H0CyAJPaLupFE2cH16KZmRC5Z9PEftiA2vWxk+CsFkPZQ6dBRdcC6RuS+zJlPc/JOd8xw3uCCt9Pv41WvQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Eoy9ycbhpJVYuR+LskV9s3uyaIkp89+qqgqhGQsWnp/I02Uqg2fXFblHJOpGZR8AxdB9ADy87oFVxn9MpFKUrw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lvwvb2SQQHctHUKvBKptR6PLFCM7JfRjpCCrDaTmvB7EeZ5/dQJPhTYBf36BE/B4CRWR2ZiBLRYhK7hhXBCZAg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-3WRYte7orvyi6TRfIZkDN9Jzoogbv+gSvR+b9VOXUg1We1XrjBg6WljADeVEaKTvOcpVdH0a90TwyOQ6ue4fGw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-tBc+W7anBPSFXGAoQW+f/+svkpt8/uXfRwDzN1DvnatkRMt16KIYpEi/iw8u9GahJlFv98kgHcIrSsZHZTR0sw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww=="], "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.9.2", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-IZZKllcaqCGsKIgeXmYFGU95IXxbBpXtwKws4Lg2GJw/qqAYYsPFEl0JBvnymSD7G1zkHYEilg5UHuTd0NmX7A=="], @@ -435,15 +435,15 @@ "@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@jridgewell/source-map": ["@jridgewell/source-map@0.3.10", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q=="], + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.30", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q=="], "@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=="], @@ -455,7 +455,7 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@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=="], @@ -465,31 +465,31 @@ "@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.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.15", "@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-UsQUMjcYTsBjTSXw0P3GO0werEQvUY2plgRQuKoCTtkNr45q1DiL51j4m7gxhABzZ0BadoXNsIbg7F3KwiUBbw=="], + "@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-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.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@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-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + "@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.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@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.15", "@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-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ=="], + "@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.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + "@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.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@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.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@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-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew=="], + "@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.7", "", { "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-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="], + "@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.4", "", { "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-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], + "@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=="], "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@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-id": "1.1.1", "@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-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "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-id": "1.1.1", "@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-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], "@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=="], @@ -561,17 +561,17 @@ "@react-native/normalize-colors": ["@react-native/normalize-colors@0.79.5", "", {}, "sha512-nGXMNMclZgzLUxijQQ38Dm3IAEhgxuySAWQHnljFtfB0JdaMwpe0Ox9H7Tp2OgrEA+EMEv+Od9ElKlHwGKmmvQ=="], - "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.4.4", "", { "dependencies": { "@react-navigation/elements": "^2.6.1", "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": "^7.1.16", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-/YEBu/cZUgYAaNoSfUnqoRjpbt8NOsb5YvDiKVyTcOOAF1GTbUw6kRi+AGW1Sm16CqzabO/TF2RvN1RmPS9VHg=="], + "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.4.6", "", { "dependencies": { "@react-navigation/elements": "^2.6.3", "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": "^7.1.17", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-f4khxwcL70O5aKfZFbxyBo5RnzPFnBNSXmrrT7q9CRmvN4mHov9KFKGQ3H4xD5sLonsTBtyjvyvPfyEC4G7f+g=="], - "@react-navigation/core": ["@react-navigation/core@7.12.3", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "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-oEz5sL8KTYmCv8SQX1A4k75A7VzYadOCudp/ewOBqRXOmZdxDQA9JuN7baE9IVyaRW0QTVDy+N/Wnqx9F4aW6A=="], + "@react-navigation/core": ["@react-navigation/core@7.12.4", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "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-xLFho76FA7v500XID5z/8YfGTvjQPw7/fXsq4BIrVSqetNe/o/v+KAocEw4ots6kyv3XvSTyiWKh2g3pN6xZ9Q=="], - "@react-navigation/elements": ["@react-navigation/elements@2.6.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.16", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-kVbIo+5FaqJv6MiYUR6nQHiw+10dmmH/P10C29wrH9S6fr7k69fImHGeiOI/h7SMDJ2vjWhftyEjqYO+c2LG/w=="], + "@react-navigation/elements": ["@react-navigation/elements@2.6.3", "", { "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.17", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-hcPXssZg5bFD5oKX7FP0D9ZXinRgPUHkUJbTegpenSEUJcPooH1qzWJkEP22GrtO+OPDLYrCVZxEX8FcMrn4pA=="], - "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.3.4", "", { "dependencies": { "@react-navigation/elements": "^2.6.1", "color": "^4.2.3", "react-native-tab-view": "^4.1.2" }, "peerDependencies": { "@react-navigation/native": "^7.1.16", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-sHBiIszq6FumBu8TboN+nVyWxgwbAER6UYULllbN87dDgnUtf+BucUYRAa+2pWeZBA2Q1esYl6VFj6pEFk2how=="], + "@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.3.6", "", { "dependencies": { "@react-navigation/elements": "^2.6.3", "color": "^4.2.3", "react-native-tab-view": "^4.1.3" }, "peerDependencies": { "@react-navigation/native": "^7.1.17", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-94r6euJ0VFnJ6Ixp4BWO9sTQjuh7dq6nEBirMRLqVZXMVZS6nsB2olw7cA8vWjQCXIM3nLNIa2t/hIzRH2yR6Q=="], - "@react-navigation/native": ["@react-navigation/native@7.1.16", "", { "dependencies": { "@react-navigation/core": "^7.12.3", "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-JnnK81JYJ6PiMsuBEshPGHwfagRnH8W7SYdWNrPxQdNtakkHtG4u0O9FmrOnKiPl45DaftCcH1g+OVTFFgWa0Q=="], + "@react-navigation/native": ["@react-navigation/native@7.1.17", "", { "dependencies": { "@react-navigation/core": "^7.12.4", "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-uEcYWi1NV+2Qe1oELfp9b5hTYekqWATv2cuwcOAg5EvsIsUPtzFrKIasgUXLBRGb9P7yR5ifoJ+ug4u6jdqSTQ=="], - "@react-navigation/native-stack": ["@react-navigation/native-stack@7.3.23", "", { "dependencies": { "@react-navigation/elements": "^2.6.1", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.16", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-WQBBnPrlM0vXj5YAFnJTyrkiCyANl2KnBV8ZmUG61HkqXFwuBbnHij6eoggXH1VZkEVRxW8k0E3qqfPtEZfUjQ=="], + "@react-navigation/native-stack": ["@react-navigation/native-stack@7.3.25", "", { "dependencies": { "@react-navigation/elements": "^2.6.3", "warn-once": "^0.1.1" }, "peerDependencies": { "@react-navigation/native": "^7.1.17", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-jGcgUpif0dDGwuqag6rKTdS78MiAVAy8vmQppyaAgjS05VbCfDX+xjhc8dUxSClO5CoWlDoby1c8Hw4kBfL2UA=="], "@react-navigation/routers": ["@react-navigation/routers@7.5.1", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-pxipMW/iEBSUrjxz2cDD7fNwkqR4xoi0E/PcfTQGCcdJwLoaxzab5kSadBLj1MTJyT0YRrOXL9umHpXtp+Dv4w=="], @@ -589,9 +589,9 @@ "@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.83.1", "", {}, "sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q=="], + "@tanstack/query-core": ["@tanstack/query-core@5.85.3", "", {}, "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ=="], - "@tanstack/react-query": ["@tanstack/react-query@5.84.0", "", { "dependencies": { "@tanstack/query-core": "5.83.1" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-iPycFGLq5lltDE16Jf13Nx7SOvtfoopfOH/+Ahbdd+z4QqOfYu/SOkY86AVYVcKjneuqPxTm8e85lSGhwe0cog=="], + "@tanstack/react-query": ["@tanstack/react-query@5.85.3", "", { "dependencies": { "@tanstack/query-core": "5.85.3" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -621,7 +621,7 @@ "@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="], - "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], + "@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], "@types/react": ["@types/react@19.0.14", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw=="], @@ -767,7 +767,7 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.25.1", "", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="], + "browserslist": ["browserslist@4.25.2", "", { "dependencies": { "caniuse-lite": "^1.0.30001733", "electron-to-chromium": "^1.5.199", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA=="], "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], @@ -795,7 +795,7 @@ "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001731", "", {}, "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001735", "", {}, "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -849,7 +849,7 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "core-js-compat": ["core-js-compat@3.44.0", "", { "dependencies": { "browserslist": "^4.25.1" } }, "sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA=="], + "core-js-compat": ["core-js-compat@3.45.0", "", { "dependencies": { "browserslist": "^4.25.1" } }, "sha512-gRoVMBawZg0OnxaVv3zpqLLxaHmsubEGyTnqdpI/CEBvX4JadI1dMSHxagThprYRtSVbuQxvi6iUatdPxohHpA=="], "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], @@ -933,7 +933,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.194", "", {}, "sha512-SdnWJwSUot04UR51I2oPD8kuP2VI37/CADR1OHsFOUzZIvfWJBO6q11k5P/uKNyTT3cdOsnyjkrZ+DDShqYqJA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.200", "", {}, "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1175,7 +1175,7 @@ "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], - "i18next": ["i18next@25.3.2", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA=="], + "i18next": ["i18next@25.3.6", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-dThZ0CTCM3sUG/qS0ZtQYZQcUI6DtBN8yBHK+SKEqihPcEYmjVWh/YJ4luic73Iq6Uxhp6q7LJJntRK5+1t7jQ=="], "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], @@ -1277,7 +1277,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.12.5", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-G8m32HW3lSmcz/4mbqx0hgJIQ0ekndKWiYP7kWVKi0p6saLXdSoye+FZiOFyonnd7Q482LCzm8sMDl7Ar1NWDw=="], + "jotai": ["jotai@2.13.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-cRsw6kFeGC9Z/D3egVKrTXRweycZ4z/k7i2MrfCzPYsL9SIWcPXTyqv258/+Ay8VUEcihNiE/coBLE6Kic6b8A=="], "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], @@ -1303,7 +1303,7 @@ "lan-network": ["lan-network@0.1.7", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ=="], - "launch-editor": ["launch-editor@2.11.0", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-R/PIF14L6e2eHkhvQPu7jDRCr0msfCYCxbYiLgkkAGi0dVPWuM+RrsPu0a5dpuNe0KWGL3jpAkOlv53xGfPheQ=="], + "launch-editor": ["launch-editor@2.11.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg=="], "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], @@ -1631,7 +1631,7 @@ "react-native-image-colors": ["react-native-image-colors@2.5.0", "", { "dependencies": { "node-vibrant": "^4.0.3" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-3zSDgNj5HaZ0PDWaXkc4BpWpZRM5N4gBsoPC7DBfM/+op69Yvwbc0S1T7CnxBWbvShtOvRE+b2BUBadVn+6z/g=="], - "react-native-ios-context-menu": ["react-native-ios-context-menu@3.1.2", "", { "dependencies": { "@dominicstop/ts-event-emitter": "^1.1.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-ios-utilities": "*" } }, "sha512-xmwdygAlmEofBzQvIhJd5qa+2DzPznmWuwkkqkI9NJbe+cfOmIzbvLdVD5RkiayewnCX9Mp8v/muf3BRWq/T1A=="], + "react-native-ios-context-menu": ["react-native-ios-context-menu@3.1.3", "", { "dependencies": { "@dominicstop/ts-event-emitter": "^1.1.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-ios-utilities": "*" } }, "sha512-p65JTOxL0D8TOgTgq3A7nVhr/hQuRTtlmsH/aQ7vaOgxY4Na/QVcEF9s4wHc7y+Rcmv84bi6V6DhqxGkFFLPmA=="], "react-native-ios-utilities": ["react-native-ios-utilities@5.1.8", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-2lWerAkd0Kn18kUAc/RaBzHnOGG1VjbKVfTR4eEXvwYFYqCS709gOg0tGUaVLsm6CAyMe7/jA+AvKMMztzHf4g=="], @@ -1639,7 +1639,7 @@ "react-native-mmkv": ["react-native-mmkv@2.12.2", "", { "peerDependencies": { "react": "*", "react-native": ">=0.71.0" } }, "sha512-6058Aq0p57chPrUutLGe9fYoiDVDNMU2PKV+lLFUJ3GhoHvUrLdsS1PDSCLr00yqzL4WJQ7TTzH+V8cpyrNcfg=="], - "react-native-pager-view": ["react-native-pager-view@6.8.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-XIyVEMhwq7sZqM7GobOJZXxFCfdFgVNq/CFB2rZIRNRSVPJqE1k1fsc8xfQKfdzsp6Rpt6I7VOIvhmP7/YHdVg=="], + "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.16.7", "", { "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" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*" } }, "sha512-qoUUQOwE1pHlmQ9cXTJ2MX9FQ9eHllopCLiWOkDkp6CER95ZWeXhJCP4cSm6AD4jigL5jHcZf/SkWrg8ttZUsw=="], @@ -1651,7 +1651,7 @@ "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-tab-view": ["react-native-tab-view@4.1.2", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-uzC1hxZGNXeQay8rSjCc7egnoYGHRpB/Y1tAwK5/nnZwrziKry7T6+gNscZgoq88+7Aag/JeNOifdWMZyRclOA=="], + "react-native-tab-view": ["react-native-tab-view@4.1.3", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-COj2HBeM4IqKCAadUdZAUWrFyO8++wlgObsgOt6xrwqdEnu9HX/74uesC0MGlgwIalFffXqTh5F3CC3pUjFPug=="], "react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="], @@ -1895,7 +1895,7 @@ "undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="], - "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], @@ -1999,8 +1999,6 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@babel/helper-module-imports/@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=="], - "@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=="], @@ -2081,8 +2079,6 @@ "@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-doctor/yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="], - "@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "@react-native/codegen/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -2193,7 +2189,7 @@ "nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "node-vibrant/@types/node": ["@types/node@18.19.121", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-bHOrbyztmyYIi4f1R0s17QsPs1uyyYnGcXeZoGEd227oZjry0q6XQBQxd82X1I57zEfwO8h9Xo+Kl5gX1d9MwQ=="], + "node-vibrant/@types/node": ["@types/node@18.19.122", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-yzegtT82dwTNEe/9y+CM8cgb42WrUfMMCg2QqSddzO1J6uPmBD7qKCZ7dOHZP2Yrpm/kb0eqdNMn2MUyEiqBmA=="], "npm-package-arg/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], @@ -2201,8 +2197,6 @@ "postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], - "postcss-load-config/yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="], - "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index e840eaee..c0f4a265 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -6,6 +6,7 @@ import type React from "react"; import { useMemo } from "react"; import { View } from "react-native"; import { apiAtom } from "@/providers/JellyfinProvider"; +import { ProgressBar } from "./common/ProgressBar"; import { WatchedIndicator } from "./WatchedIndicator"; type ContinueWatchingPosterProps = { @@ -62,18 +63,6 @@ const ContinueWatchingPoster: React.FC = ({ return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; }, [item]); - const progress = useMemo(() => { - if (item.Type === "Program") { - const startDate = new Date(item.StartDate || ""); - const endDate = new Date(item.EndDate || ""); - const now = new Date(); - const total = endDate.getTime() - startDate.getTime(); - const elapsed = now.getTime() - startDate.getTime(); - return (elapsed / total) * 100; - } - return item.UserData?.PlayedPercentage || 0; - }, [item]); - if (!url) return ; @@ -101,22 +90,8 @@ const ContinueWatchingPoster: React.FC = ({ )} - {!progress && } - {progress > 0 && ( - <> - - - - )} + {!item.UserData?.Played && } + ); }; diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 552484eb..990f9f0d 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -14,16 +14,14 @@ import { t } from "i18next"; import { useAtom } from "jotai"; import type React from "react"; import { useCallback, useMemo, useRef, useState } from "react"; -import { Alert, Platform, View, type ViewProps } from "react-native"; +import { Alert, Platform, Switch, View, type ViewProps } from "react-native"; import { toast } from "sonner-native"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { queueAtom } from "@/utils/atoms/queue"; -import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; +import { useSettings } from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server"; -import download from "@/utils/profiles/download"; +import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl"; import { AudioTrackSelector } from "./AudioTrackSelector"; import { type Bitrate, BitrateSelector } from "./BitrateSelector"; import { Button } from "./Button"; @@ -54,11 +52,13 @@ export const DownloadItems: React.FC = ({ }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const [queue, setQueue] = useAtom(queueAtom); + const [queue, _setQueue] = useAtom(queueAtom); const [settings] = useSettings(); + const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false); - const { processes, startBackgroundDownload, downloadedFiles } = useDownload(); - //const { startRemuxing } = useRemuxHlsToMp4(); + const { processes, startBackgroundDownload, getDownloadedItems } = + useDownload(); + const downloadedFiles = getDownloadedItems(); const [selectedMediaSource, setSelectedMediaSource] = useState< MediaSourceInfo | undefined | null @@ -77,10 +77,6 @@ export const DownloadItems: React.FC = ({ () => user?.Policy?.EnableContentDownloading, [user], ); - const usingOptimizedServer = useMemo( - () => settings?.downloadMethod === DownloadMethod.Optimized, - [settings], - ); const bottomSheetModalRef = useRef(null); @@ -102,6 +98,13 @@ export const DownloadItems: React.FC = ({ [items, downloadedFiles], ); + const itemsToDownload = useMemo(() => { + if (downloadUnwatchedOnly) { + return itemsNotDownloaded.filter((item) => !item.UserData?.Played); + } + return itemsNotDownloaded; + }, [itemsNotDownloaded, downloadUnwatchedOnly]); + const allItemsDownloaded = useMemo(() => { if (items.length === 0) return false; return itemsNotDownloaded.length === 0; @@ -144,31 +147,6 @@ export const DownloadItems: React.FC = ({ ); }; - const acceptDownloadOptions = useCallback(() => { - if (userCanDownload === true) { - if (itemsNotDownloaded.some((i) => !i.Id)) { - throw new Error("No item id"); - } - closeModal(); - - initiateDownload(...itemsNotDownloaded); - } else { - toast.error( - t("home.downloads.toasts.you_are_not_allowed_to_download_files"), - ); - } - }, [ - queue, - setQueue, - itemsNotDownloaded, - usingOptimizedServer, - userCanDownload, - maxBitrate, - selectedMediaSource, - selectedAudioStream, - selectedSubtitleStream, - ]); - const initiateDownload = useCallback( async (...items: BaseItemDto[]) => { if ( @@ -181,46 +159,53 @@ export const DownloadItems: React.FC = ({ "DownloadItem ~ initiateDownload: No api or user or item", ); } - let mediaSource = selectedMediaSource; - let audioIndex: number | undefined = selectedAudioStream; - let subtitleIndex: number | undefined = selectedSubtitleStream; + const downloadDetailsPromises = items.map(async (item) => { + const { mediaSource, audioIndex, subtitleIndex } = + itemsNotDownloaded.length > 1 + ? getDefaultPlaySettings(item, settings!) + : { + mediaSource: selectedMediaSource, + audioIndex: selectedAudioStream, + subtitleIndex: selectedSubtitleStream, + }; - for (const item of items) { - if (itemsNotDownloaded.length > 1) { - const defaults = getDefaultPlaySettings(item, settings!); - mediaSource = defaults.mediaSource; - audioIndex = defaults.audioIndex; - subtitleIndex = defaults.subtitleIndex; - } - - const res = await getStreamUrl({ + const downloadDetails = await getDownloadUrl({ api, item, - startTimeTicks: 0, - userId: user?.Id, - audioStreamIndex: audioIndex, - maxStreamingBitrate: maxBitrate.value, - mediaSourceId: mediaSource?.Id, - subtitleStreamIndex: subtitleIndex, - deviceProfile: download, - download: true, - // deviceId: mediaSource?.Id, + userId: user.Id!, + mediaSource: mediaSource!, + audioStreamIndex: audioIndex ?? -1, + subtitleStreamIndex: subtitleIndex ?? -1, + maxBitrate, + deviceId: api.deviceInfo.id, }); - if (!res) { + return { + url: downloadDetails?.url, + item, + mediaSource: downloadDetails?.mediaSource, + }; + }); + + const downloadDetails = await Promise.all(downloadDetailsPromises); + for (const { url, item, mediaSource } of downloadDetails) { + if (!url) { Alert.alert( t("home.downloads.something_went_wrong"), t("home.downloads.could_not_get_stream_url_from_jellyfin"), ); continue; } - - const { mediaSource: source, url } = res; - - if (!url || !source) throw new Error("No url"); - - saveDownloadItemInfoToDiskTmp(item, source, url); - await startBackgroundDownload(url, item, source, maxBitrate); + if (!mediaSource) { + console.error(`Could not get download URL for ${item.Name}`); + toast.error( + t("Could not get download URL for {{itemName}}", { + itemName: item.Name, + }), + ); + continue; + } + await startBackgroundDownload(url, item, mediaSource, maxBitrate); } }, [ @@ -232,11 +217,25 @@ export const DownloadItems: React.FC = ({ selectedSubtitleStream, settings, maxBitrate, - usingOptimizedServer, startBackgroundDownload, ], ); + const acceptDownloadOptions = useCallback(() => { + if (userCanDownload === true) { + if (itemsToDownload.some((i) => !i.Id)) { + throw new Error("No item id"); + } + closeModal(); + + initiateDownload(...itemsToDownload); + } else { + toast.error( + t("home.downloads.toasts.you_are_not_allowed_to_download_files"), + ); + } + }, [closeModal, initiateDownload, itemsToDownload, userCanDownload]); + const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( = ({ if (itemsNotDownloaded.length !== 1) return; const { bitrate, mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(items[0], settings); - setSelectedMediaSource(mediaSource ?? undefined); setSelectedAudioStream(audioIndex ?? 0); setSelectedSubtitleStream(subtitleIndex ?? -1); @@ -327,7 +325,7 @@ export const DownloadItems: React.FC = ({ {subtitle || t("item_card.download.download_x_item", { - item_count: itemsNotDownloaded.length, + item_count: itemsToDownload.length, })} @@ -337,6 +335,15 @@ export const DownloadItems: React.FC = ({ onChange={setMaxBitrate} selected={maxBitrate} /> + {itemsNotDownloaded.length > 1 && ( + + {t("item_card.download.download_unwatched_only")} + + + )} {itemsNotDownloaded.length === 1 && ( <> = ({ )} + - - - {usingOptimizedServer - ? t("item_card.download.using_optimized_server") - : t("item_card.download.using_default_method")} - - diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index d7a5d598..83c303f7 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -45,8 +45,13 @@ export type SelectedOptions = { subtitleIndex: number; }; -export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( - ({ item }) => { +interface ItemContentProps { + item: BaseItemDto; + isOffline: boolean; +} + +export const ItemContent: React.FC = React.memo( + ({ item, isOffline }) => { const [api] = useAtom(apiAtom); const [settings] = useSettings(); const { orientation } = useOrientation(); @@ -68,7 +73,16 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( defaultBitrate, defaultMediaSource, defaultSubtitleIndex, - } = useDefaultPlaySettings(item, settings); + } = useDefaultPlaySettings(item!, settings); + + const logoUrl = useMemo( + () => (item ? getLogoImageUrlById({ api, item }) : null), + [api, item], + ); + + const loading = useMemo(() => { + return Boolean(logoUrl && loadingLogo); + }, [loadingLogo, logoUrl]); // Needs to automatically change the selected to the default values for default indexes. useEffect(() => { @@ -116,22 +130,15 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( }, [item, navigation, user]); useEffect(() => { - if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) - setHeaderHeight(230); - else if (item.Type === "Movie") setHeaderHeight(500); - else setHeaderHeight(350); - }, [item.Type, orientation]); + if (item) { + if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) + setHeaderHeight(230); + else if (item.Type === "Movie") setHeaderHeight(500); + else setHeaderHeight(350); + } + }, [item, orientation]); - const logoUrl = useMemo( - () => getLogoImageUrlById({ api, item }), - [api, item], - ); - - const loading = useMemo(() => { - return Boolean(logoUrl && loadingLogo); - }, [loadingLogo, logoUrl]); - - if (!selectedOptions) return ; + if (!item || !selectedOptions) return null; return ( = React.memo( - {item.Type !== "Program" && !Platform.isTV && ( + {item.Type !== "Program" && !Platform.isTV && !isOffline && ( = React.memo( className='grow' selectedOptions={selectedOptions} item={item} + isOffline={isOffline} /> {item.Type === "Episode" && ( - + )} - + {!isOffline && ( + + )} {item.Type !== "Program" && ( <> - {item.Type === "Episode" && ( + {item.Type === "Episode" && !isOffline && ( )} - + {!isOffline && ( + + )} - {item.People && item.People.length > 0 && ( + {item.People && item.People.length > 0 && !isOffline && ( {item.People.slice(0, 3).map((person, idx) => ( = React.memo( )} - + {!isOffline && } )} diff --git a/components/ItemHeader.tsx b/components/ItemHeader.tsx index b9e3006d..51cf4d0a 100644 --- a/components/ItemHeader.tsx +++ b/components/ItemHeader.tsx @@ -33,16 +33,16 @@ export const ItemHeader: React.FC = ({ item, ...props }) => { {item.Type === "Episode" && ( - <> + - + )} {item.Type === "Movie" && ( - <> + - + )} diff --git a/components/ItemTechnicalDetails.tsx b/components/ItemTechnicalDetails.tsx index 81c04e0a..c0d5e54d 100644 --- a/components/ItemTechnicalDetails.tsx +++ b/components/ItemTechnicalDetails.tsx @@ -236,6 +236,7 @@ const formatFileSize = (bytes?: number | null) => { if (bytes === 0) return "0 Byte"; const i = Number.parseInt( Math.floor(Math.log(bytes) / Math.log(1024)).toString(), + 10, ); return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`; }; diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index e6ba6218..e013a826 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -38,6 +38,7 @@ import type { SelectedOptions } from "./ItemContent"; interface Props extends React.ComponentProps { item: BaseItemDto; selectedOptions: SelectedOptions; + isOffline?: boolean; } const ANIMATION_DURATION = 500; @@ -46,6 +47,7 @@ const MIN_PLAYBACK_WIDTH = 15; export const PlayButton: React.FC = ({ item, selectedOptions, + isOffline, ...props }: Props) => { const { showActionSheetWithOptions } = useActionSheet(); @@ -75,7 +77,7 @@ export const PlayButton: React.FC = ({ } router.push(`/player/direct-player?${q}`); }, - [router], + [router, isOffline], ); const onPress = useCallback(async () => { @@ -90,6 +92,8 @@ export const PlayButton: React.FC = ({ subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", + playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0", + offline: isOffline ? "true" : "false", }); const queryString = queryParams.toString(); diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx index cab1e34f..e055b8cd 100644 --- a/components/PlayedStatus.tsx +++ b/components/PlayedStatus.tsx @@ -1,5 +1,4 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useQueryClient } from "@tanstack/react-query"; import type React from "react"; import { View, type ViewProps } from "react-native"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; @@ -7,44 +6,13 @@ import { RoundButton } from "./RoundButton"; interface Props extends ViewProps { items: BaseItemDto[]; + isOffline?: boolean; size?: "default" | "large"; } export const PlayedStatus: React.FC = ({ items, ...props }) => { - const queryClient = useQueryClient(); - - const _invalidateQueries = () => { - items.forEach((item) => { - queryClient.invalidateQueries({ - queryKey: ["item", item.Id], - }); - }); - queryClient.invalidateQueries({ - queryKey: ["resumeItems"], - }); - queryClient.invalidateQueries({ - queryKey: ["continueWatching"], - }); - queryClient.invalidateQueries({ - queryKey: ["nextUp-all"], - }); - queryClient.invalidateQueries({ - queryKey: ["nextUp"], - }); - queryClient.invalidateQueries({ - queryKey: ["episodes"], - }); - queryClient.invalidateQueries({ - queryKey: ["seasons"], - }); - queryClient.invalidateQueries({ - queryKey: ["home"], - }); - }; - const allPlayed = items.every((item) => item.UserData?.Played); - - const markAsPlayedStatus = useMarkAsPlayed(items); + const toggle = useMarkAsPlayed(items); return ( @@ -52,8 +20,7 @@ export const PlayedStatus: React.FC = ({ items, ...props }) => { fillColor={allPlayed ? "primary" : undefined} icon={allPlayed ? "checkmark" : "checkmark"} onPress={async () => { - console.log(allPlayed); - await markAsPlayedStatus(!allPlayed); + await toggle(!allPlayed); }} size={props.size} /> diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 81d3b885..de3f4697 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -20,8 +20,7 @@ export const SubtitleTrackSelector: React.FC = ({ selected, ...props }) => { - const isTv = Platform.isTV; - + const { t } = useTranslation(); const subtitleStreams = useMemo(() => { return source?.MediaStreams?.filter((x) => x.Type === "Subtitle"); }, [source]); @@ -31,10 +30,7 @@ export const SubtitleTrackSelector: React.FC = ({ [subtitleStreams, selected], ); - const { t } = useTranslation(); - - if (isTv) return null; - if (subtitleStreams?.length === 0) return null; + if (Platform.isTV || subtitleStreams?.length === 0) return null; return ( > = ({ onPress={() => { if (!result) return; - // @ts-ignore + // @ts-expect-error router.push({ pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: { diff --git a/components/common/ProgressBar.tsx b/components/common/ProgressBar.tsx new file mode 100644 index 00000000..1a47327e --- /dev/null +++ b/components/common/ProgressBar.tsx @@ -0,0 +1,47 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import React, { useMemo } from "react"; +import { View } from "react-native"; + +interface ProgressBarProps { + item: BaseItemDto; +} + +export const ProgressBar: React.FC = ({ item }) => { + const progress = useMemo(() => { + if (item.Type === "Program") { + if (!item.StartDate || !item.EndDate) { + return 0; + } + const startDate = new Date(item.StartDate); + const endDate = new Date(item.EndDate); + const now = new Date(); + const total = endDate.getTime() - startDate.getTime(); + if (total <= 0) { + return 0; + } + const elapsed = now.getTime() - startDate.getTime(); + return (elapsed / total) * 100; + } + return item.UserData?.PlayedPercentage || 0; + }, [item]); + + if (progress <= 0) { + return null; + } + + return ( + <> + + + + ); +}; diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index a0832304..5c44ab4f 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -11,6 +11,7 @@ import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; interface Props extends TouchableOpacityProps { item: BaseItemDto; + isOffline?: boolean; } export const itemRouter = ( @@ -50,6 +51,7 @@ export const itemRouter = ( export const TouchableItemRouter: React.FC> = ({ item, + isOffline = false, children, ...props }) => { @@ -105,7 +107,10 @@ export const TouchableItemRouter: React.FC> = ({ { - const url = itemRouter(item, from); + let url = itemRouter(item, from); + if (isOffline) { + url += `&offline=true`; + } // @ts-expect-error router.push(url); }} @@ -114,4 +119,6 @@ export const TouchableItemRouter: React.FC> = ({ {children} ); + + return null; }; diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 08648f94..e95ba2d2 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -6,7 +6,6 @@ import { t } from "i18next"; import { useMemo } from "react"; import { ActivityIndicator, - Platform, TouchableOpacity, type TouchableOpacityProps, View, @@ -15,18 +14,17 @@ import { import { toast } from "sonner-native"; import { Text } from "@/components/common/Text"; import { useDownload } from "@/providers/DownloadProvider"; -import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; +import { JobStatus } from "@/providers/Downloads/types"; import { storage } from "@/utils/mmkv"; -import type { JobStatus } from "@/utils/optimize-server"; import { formatTimeString } from "@/utils/time"; import { Button } from "../Button"; -const BackGroundDownloader = !Platform.isTV - ? require("@kesha-antonov/react-native-background-downloader") - : null; - interface Props extends ViewProps {} +const bytesToMB = (bytes: number) => { + return bytes / 1024 / 1024; +}; + export const ActiveDownloads: React.FC = ({ ...props }) => { const { processes } = useDownload(); if (processes?.length === 0) @@ -60,32 +58,18 @@ interface DownloadCardProps extends TouchableOpacityProps { } const DownloadCard = ({ process, ...props }: DownloadCardProps) => { - const { startDownload } = useDownload(); + const { startDownload, removeProcess } = useDownload(); const router = useRouter(); - const { removeProcess } = useDownload(); - const [settings] = useSettings(); const queryClient = useQueryClient(); const cancelJobMutation = useMutation({ mutationFn: async (id: string) => { if (!process) throw new Error("No active download"); - - try { - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - for (const task of tasks) { - if (task.id === id) { - task.stop(); - } - } - } finally { - await removeProcess(id); - if (settings?.downloadMethod === DownloadMethod.Optimized) { - await queryClient.refetchQueries({ queryKey: ["jobs"] }); - } - } + removeProcess(id); }, onSuccess: () => { toast.success(t("home.downloads.toasts.download_cancelled")); + queryClient.invalidateQueries({ queryKey: ["downloads"] }); }, onError: (e) => { console.error(e); @@ -94,11 +78,14 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { }); const eta = (p: JobStatus) => { - if (!p.speed || !p.progress) return null; + if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null; - const length = p?.item?.RunTimeTicks || 0; - const timeLeft = (length - length * (p.progress / 100)) / p.speed; - return formatTimeString(timeLeft, "tick"); + const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0); + if (bytesRemaining <= 0) return null; + + const secondsRemaining = bytesRemaining / p.speed; + + return formatTimeString(secondsRemaining, "s"); }; const base64Image = useMemo(() => { @@ -111,8 +98,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden' {...props} > - {(process.status === "optimizing" || - process.status === "downloading") && ( + {process.status === "downloading" && ( { ) : ( {process.progress.toFixed(0)}% )} - {process.speed && ( - {process.speed?.toFixed(2)}x + {process.speed && process.speed > 0 && ( + + {bytesToMB(process.speed).toFixed(2)} MB/s + )} {eta(process) && ( diff --git a/components/downloads/DownloadSize.tsx b/components/downloads/DownloadSize.tsx index aa1beb37..c6316ff9 100644 --- a/components/downloads/DownloadSize.tsx +++ b/components/downloads/DownloadSize.tsx @@ -13,7 +13,8 @@ export const DownloadSize: React.FC = ({ items, ...props }) => { - const { downloadedFiles, getDownloadedItemSize } = useDownload(); + const { getDownloadedItemSize, getDownloadedItems } = useDownload(); + const downloadedFiles = getDownloadedItems(); const [size, setSize] = useState(); const itemIds = useMemo(() => items.map((i) => i.Id), [items]); diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx index 8dc0c7a5..83c0a4e7 100644 --- a/components/downloads/EpisodeCard.tsx +++ b/components/downloads/EpisodeCard.tsx @@ -4,18 +4,13 @@ import { } from "@expo/react-native-action-sheet"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type React from "react"; -import { useCallback, useMemo } from "react"; -import { - TouchableOpacity, - type TouchableOpacityProps, - View, -} from "react-native"; +import { useCallback } from "react"; +import { type TouchableOpacityProps, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { DownloadSize } from "@/components/downloads/DownloadSize"; -import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; import { useHaptic } from "@/hooks/useHaptic"; import { useDownload } from "@/providers/DownloadProvider"; -import { storage } from "@/utils/mmkv"; import { runtimeTicksToSeconds } from "@/utils/time"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; @@ -25,24 +20,15 @@ interface EpisodeCardProps extends TouchableOpacityProps { export const EpisodeCard: React.FC = ({ item }) => { const { deleteFile } = useDownload(); - const { openFile } = useDownloadedFileOpener(); const { showActionSheetWithOptions } = useActionSheet(); const successHapticFeedback = useHaptic("success"); - const _base64Image = useMemo(() => { - return storage.getString(item.Id!); - }, [item]); - - const handleOpenFile = useCallback(() => { - openFile(item); - }, [item, openFile]); - /** * Handles deleting the file with haptic feedback. */ const handleDeleteFile = useCallback(() => { if (item.Id) { - deleteFile(item.Id); + deleteFile(item.Id, "Episode"); successHapticFeedback(); } }, [deleteFile, item.Id]); @@ -73,10 +59,10 @@ export const EpisodeCard: React.FC = ({ item }) => { }, [showActionSheetWithOptions, handleDeleteFile]); return ( - @@ -100,7 +86,7 @@ export const EpisodeCard: React.FC = ({ item }) => { {item.Overview} - + ); }; diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx index 5a79ae13..c193d562 100644 --- a/components/downloads/MovieCard.tsx +++ b/components/downloads/MovieCard.tsx @@ -7,12 +7,12 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; import type React from "react"; import { useCallback, useMemo } from "react"; -import { TouchableOpacity, View } from "react-native"; +import { View } from "react-native"; import { DownloadSize } from "@/components/downloads/DownloadSize"; -import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; -import { useHaptic } from "@/hooks/useHaptic"; import { useDownload } from "@/providers/DownloadProvider"; import { storage } from "@/utils/mmkv"; +import { ProgressBar } from "../common/ProgressBar"; +import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { ItemCardText } from "../ItemCardText"; interface MovieCardProps { @@ -26,16 +26,10 @@ interface MovieCardProps { */ export const MovieCard: React.FC = ({ item }) => { const { deleteFile } = useDownload(); - const { openFile } = useDownloadedFileOpener(); const { showActionSheetWithOptions } = useActionSheet(); - const successHapticFeedback = useHaptic("success"); - - const handleOpenFile = useCallback(() => { - openFile(item); - }, [item, openFile]); const base64Image = useMemo(() => { - return storage.getString(item.Id!); + return storage.getString(item?.Id!); }, []); /** @@ -43,8 +37,7 @@ export const MovieCard: React.FC = ({ item }) => { */ const handleDeleteFile = useCallback(() => { if (item.Id) { - deleteFile(item.Id); - successHapticFeedback(); + deleteFile(item.Id, "Movie"); } }, [deleteFile, item.Id]); @@ -74,9 +67,9 @@ export const MovieCard: React.FC = ({ item }) => { }, [showActionSheetWithOptions, handleDeleteFile]); return ( - + {base64Image ? ( - + = ({ item }) => { resizeMode: "cover", }} /> + ) : ( - + + )} - + ); }; diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx index bc0b6479..1e300576 100644 --- a/components/home/LargeMovieCarousel.tsx +++ b/components/home/LargeMovieCarousel.tsx @@ -154,7 +154,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => { if (!from) return; const url = itemRouter(item, from); lightHapticFeedback(); - // @ts-ignore + // @ts-expect-error if (url) router.push(url); }, [item, from]); diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 483fd156..dc18b464 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -20,6 +20,7 @@ interface Props extends ViewProps { queryKey: QueryKey; queryFn: QueryFunction; hideIfEmpty?: boolean; + isOffline?: boolean; } export const ScrollingCollectionList: React.FC = ({ @@ -29,6 +30,7 @@ export const ScrollingCollectionList: React.FC = ({ queryFn, queryKey, hideIfEmpty = false, + isOffline = false, ...props }) => { const { data, isLoading } = useQuery({ @@ -90,6 +92,7 @@ export const ScrollingCollectionList: React.FC = ({ ({ } nestedScrollEnabled showsVerticalScrollIndicator={false} - //@ts-ignore + //@ts-expect-error renderItem={({ item, index }) => renderItem(item, index)} keyExtractor={keyExtractor} numColumns={3} diff --git a/components/jellyseerr/discover/Slide.tsx b/components/jellyseerr/discover/Slide.tsx index 5edb49f5..7352fb6c 100644 --- a/components/jellyseerr/discover/Slide.tsx +++ b/components/jellyseerr/discover/Slide.tsx @@ -49,7 +49,7 @@ const Slide = ({ data={data} onEndReachedThreshold={1} onEndReached={onEndReached} - //@ts-ignore + //@ts-expect-error renderItem={({ item, index }) => item ? renderItem(item, index) : null } diff --git a/components/search/SearchItemWrapper.tsx b/components/search/SearchItemWrapper.tsx index 0db091c0..91744c98 100644 --- a/components/search/SearchItemWrapper.tsx +++ b/components/search/SearchItemWrapper.tsx @@ -35,11 +35,11 @@ export const SearchItemWrapper = ({ showsHorizontalScrollIndicator={false} keyExtractor={(_, index) => index.toString()} estimatedItemSize={250} - /*@ts-ignore */ + /*@ts-expect-error */ data={items} onEndReachedThreshold={1} onEndReached={onEndReached} - //@ts-ignore + //@ts-expect-error renderItem={({ item }) => (item ? renderItem(item) : null)} /> diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx index 60fb9012..414453b9 100644 --- a/components/series/CastAndCrew.tsx +++ b/components/series/CastAndCrew.tsx @@ -55,7 +55,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => { { const url = itemRouter(i, from); - // @ts-ignore + // @ts-expect-error router.push(url); }} className='flex flex-col w-28' diff --git a/components/series/EpisodeTitleHeader.tsx b/components/series/EpisodeTitleHeader.tsx index 7f7aa154..c0e7f450 100644 --- a/components/series/EpisodeTitleHeader.tsx +++ b/components/series/EpisodeTitleHeader.tsx @@ -19,7 +19,7 @@ export const EpisodeTitleHeader: React.FC = ({ item, ...props }) => { { router.push( - // @ts-ignore + // @ts-expect-error `/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}`, ); }} diff --git a/components/series/SeasonDropdown.tsx b/components/series/SeasonDropdown.tsx index 6cfef3c2..e9b4f0e7 100644 --- a/components/series/SeasonDropdown.tsx +++ b/components/series/SeasonDropdown.tsx @@ -94,7 +94,6 @@ export const SeasonDropdown: React.FC = ({ item[keys.id], initialSeasonIndex, keys, - onSelect, ]); const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => @@ -123,16 +122,18 @@ export const SeasonDropdown: React.FC = ({ sideOffset={8} > {t("item_card.seasons")} - {seasons?.sort(sortByIndex).map((season: any) => ( - onSelect(season)} - > - - {season[keys.title]} - - - ))} + {seasons?.sort(sortByIndex).map((season: any) => { + const title = + season[keys.title] || season.Name || `Season ${season.IndexNumber}`; + return ( + onSelect(season)} + > + {title} + + ); + })} ); diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx index d731d145..5a3304ad 100644 --- a/components/series/SeasonEpisodesCarousel.tsx +++ b/components/series/SeasonEpisodesCarousel.tsx @@ -1,9 +1,11 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { router } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useMemo, useRef } from "react"; import { TouchableOpacity, type ViewProps } from "react-native"; +import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; @@ -16,15 +18,19 @@ import { ItemCardText } from "../ItemCardText"; interface Props extends ViewProps { item?: BaseItemDto | null; loading?: boolean; + isOffline?: boolean; } export const SeasonEpisodesCarousel: React.FC = ({ item, loading, + isOffline, ...props }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); + const { getDownloadedItems } = useDownload(); + const downloadedFiles = getDownloadedItems(); const scrollRef = useRef(null); @@ -41,24 +47,28 @@ export const SeasonEpisodesCarousel: React.FC = ({ isLoading, isFetching, } = useQuery({ - queryKey: ["episodes", seasonId], + queryKey: ["episodes", seasonId, isOffline], queryFn: async () => { - if (!api || !user?.Id) return []; - const response = await api.axiosInstance.get( - `${api.basePath}/Shows/${item?.Id}/Episodes`, - { - params: { - userId: user?.Id, - seasonId, - Fields: - "ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview", - }, - headers: { - Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, - }, - }, - ); - + if (isOffline) { + return downloadedFiles + ?.filter( + (f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId, + ) + .map((f) => f.item); + } + if (!api || !user?.Id || !item?.SeriesId) return []; + const response = await getTvShowsApi(api).getEpisodes({ + userId: user.Id, + seasonId: seasonId || undefined, + seriesId: item.SeriesId, + fields: [ + "ItemCounts", + "PrimaryImageAspectRatio", + "CanDelete", + "MediaSourceCount", + "Overview", + ], + }); return response.data.Items as BaseItemDto[]; }, enabled: !!api && !!user?.Id && !!seasonId, diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index 7f50fa4d..f617ae8b 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -86,7 +86,8 @@ export const SeasonPicker: React.FC = ({ item }) => { userId: user.Id, seasonId: selectedSeasonId, enableUserData: true, - fields: ["MediaSources", "MediaStreams", "Overview"], + // Note: Including trick play is necessary to enable trick play downloads + fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], }); if (res.data.TotalRecordCount === 0) @@ -97,6 +98,10 @@ export const SeasonPicker: React.FC = ({ item }) => { return res.data.Items; }, + select: (data) => + [...(data || [])].sort( + (a, b) => (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0), + ), enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, }); diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx index dc4bb669..aed633e7 100644 --- a/components/settings/DownloadSettings.tsx +++ b/components/settings/DownloadSettings.tsx @@ -1,34 +1,17 @@ -import { Ionicons } from "@expo/vector-icons"; -import { useQueryClient } from "@tanstack/react-query"; -import { useRouter } from "expo-router"; import { useMemo } from "react"; -import { Platform, Switch, TouchableOpacity } from "react-native"; -import { Stepper } from "@/components/inputs/Stepper"; -import { useDownload } from "@/providers/DownloadProvider"; -import { - DownloadMethod, - type Settings, - useSettings, -} from "@/utils/atoms/settings"; - -const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; - import { useTranslation } from "react-i18next"; +import { Stepper } from "@/components/inputs/Stepper"; import DisabledSetting from "@/components/settings/DisabledSetting"; -import { Text } from "../common/Text"; +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 { setProcesses } = useDownload(); - const router = useRouter(); - const queryClient = useQueryClient(); const { t } = useTranslation(); const allDisabled = useMemo( () => - pluginSettings?.downloadMethod?.locked === true && pluginSettings?.remuxConcurrentLimit?.locked === true && pluginSettings?.autoDownload.locked === true, [pluginSettings], @@ -39,70 +22,9 @@ export default function DownloadSettings({ ...props }) { return ( - - - - - - {settings.downloadMethod === DownloadMethod.Remux - ? t("home.settings.downloads.default") - : t("home.settings.downloads.optimized")} - - - - - - - {t("home.settings.downloads.download_method")} - - { - updateSettings({ downloadMethod: DownloadMethod.Remux }); - setProcesses([]); - }} - > - - {t("home.settings.downloads.default")} - - - { - updateSettings({ downloadMethod: DownloadMethod.Optimized }); - setProcesses([]); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} - > - - {t("home.settings.downloads.optimized")} - - - - - - - - - updateSettings({ autoDownload: value })} - /> - - - router.push("/settings/optimized-server/page")} - showArrow - title={t("home.settings.downloads.optimized_versions_server")} - /> ); diff --git a/components/settings/HomeIndex.tsx b/components/settings/HomeIndex.tsx index bef4a6ae..80543e51 100644 --- a/components/settings/HomeIndex.tsx +++ b/components/settings/HomeIndex.tsx @@ -82,6 +82,17 @@ export const HomeIndex = () => { const scrollViewRef = useRef(null); const { downloadedFiles, cleanCacheDirectory } = useDownload(); + const prevIsConnected = useRef(false); + 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]); + useEffect(() => { if (Platform.isTV) { navigation.setOptions({ @@ -144,10 +155,6 @@ export const HomeIndex = () => { setIsConnected(state.isConnected); }); - // cleanCacheDirectory().catch((e) => - // console.error("Something went wrong cleaning cache directory") - // ); - return () => { unsubscribe(); }; @@ -188,8 +195,6 @@ export const HomeIndex = () => { ); }, [userViews]); - const invalidateCache = useInvalidatePlaybackProgressCache(); - const refetch = async () => { setLoading(true); await refreshStreamyfinPluginSettings(); diff --git a/components/settings/OptimizedServerForm.tsx b/components/settings/OptimizedServerForm.tsx deleted file mode 100644 index f4f75791..00000000 --- a/components/settings/OptimizedServerForm.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Linking, TextInput, View } from "react-native"; -import { Text } from "../common/Text"; - -interface Props { - value: string; - onChangeValue: (value: string) => void; -} - -export const OptimizedServerForm: React.FC = ({ - value, - onChangeValue, -}) => { - const handleOpenLink = () => { - Linking.openURL("https://github.com/streamyfin/optimized-versions-server"); - }; - - const { t } = useTranslation(); - - return ( - - - - {t("home.settings.downloads.url")} - onChangeValue(text)} - /> - - - - {t("home.settings.downloads.optimized_version_hint")}{" "} - - {t("home.settings.downloads.read_more_about_optimized_server")} - - - - ); -}; diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx index 5e3c278e..02c2ed0a 100644 --- a/components/settings/StorageSettings.tsx +++ b/components/settings/StorageSettings.tsx @@ -17,9 +17,9 @@ export const StorageSettings = () => { const errorHapticFeedback = useHaptic("error"); const { data: size } = useQuery({ - queryKey: ["appSize", appSizeUsage], + queryKey: ["appSize"], queryFn: async () => { - const app = await appSizeUsage; + const app = await appSizeUsage(); const remaining = await FileSystem.getFreeDiskStorageAsync(); const total = await FileSystem.getTotalDiskCapacityAsync(); @@ -58,7 +58,7 @@ export const StorageSettings = () => { {size && ( - <> + { /> - + )} {size && ( - <> + @@ -99,7 +96,7 @@ export const StorageSettings = () => { })} - + )} diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 4b33ad6b..b9a1ec80 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -35,10 +35,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay"; -import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes"; import { useCreditSkipper } from "@/hooks/useCreditSkipper"; import { useHaptic } from "@/hooks/useHaptic"; import { useIntroSkipper } from "@/hooks/useIntroSkipper"; +import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useTrickplay } from "@/hooks/useTrickplay"; import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types"; import { apiAtom } from "@/providers/JellyfinProvider"; @@ -82,8 +82,8 @@ interface Props { isVideoLoaded?: boolean; mediaSource?: MediaSourceInfo | null; seek: (ticks: number) => void; - startPictureInPicture: () => Promise; - play: (() => Promise) | (() => void); + startPictureInPicture?: () => Promise; + play: () => void; pause: () => void; getAudioTracks?: (() => Promise) | (() => TrackInfo[]); getSubtitleTracks?: (() => Promise) | (() => TrackInfo[]); @@ -119,7 +119,6 @@ export const Controls: FC = ({ setSubtitleTrack, setAudioTrack, offline = false, - enableTrickplay = true, isVlc = false, }) => { const [settings, updateSettings] = useSettings(); @@ -134,13 +133,17 @@ export const Controls: FC = ({ const [showAudioSlider, setShowAudioSlider] = useState(false); const { height: screenHeight, width: screenWidth } = useWindowDimensions(); - const { previousItem, nextItem } = useAdjacentItems({ item }); + const { previousItem, nextItem } = usePlaybackManager({ + item, + isOffline: offline, + }); + const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo, prefetchAllTrickplayImages, - } = useTrickplay(item, !offline && enableTrickplay); + } = useTrickplay(item); const [currentTime, setCurrentTime] = useState(0); const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY); @@ -303,19 +306,21 @@ export const Controls: FC = ({ }>(); const { showSkipButton, skipIntro } = useIntroSkipper( - offline ? undefined : item.Id, + item?.Id!, currentTime, seek, play, isVlc, + offline, ); const { showSkipCreditButton, skipCredit } = useCreditSkipper( - offline ? undefined : item.Id, + item?.Id!, currentTime, seek, play, isVlc, + offline, ); const goToItemCommon = useCallback( @@ -323,14 +328,12 @@ export const Controls: FC = ({ if (!item || !settings) { return; } - lightHapticFeedback(); - const previousIndexes = { subtitleIndex: subtitleIndex - ? Number.parseInt(subtitleIndex) + ? Number.parseInt(subtitleIndex, 10) : undefined, - audioIndex: audioIndex ? Number.parseInt(audioIndex) : undefined, + audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined, }; const { @@ -343,15 +346,18 @@ export const Controls: FC = ({ previousIndexes, mediaSource ?? undefined, ); - const queryParams = new URLSearchParams({ itemId: item.Id ?? "", audioIndex: defaultAudioIndex?.toString() ?? "", subtitleIndex: defaultSubtitleIndex?.toString() ?? "", mediaSourceId: newMediaSource?.Id ?? "", bitrateValue: bitrateValue?.toString(), + playbackPosition: + item.UserData?.PlaybackPositionTicks?.toString() ?? "", }).toString(); + console.log("queryParams", queryParams); + // @ts-expect-error router.replace(`player/direct-player?${queryParams}`); }, @@ -434,10 +440,18 @@ export const Controls: FC = ({ const goToItem = useCallback( async (itemId: string) => { - const gotoItem = await getItemById(api, itemId); - if (!gotoItem) { + if (offline) { + const queryParams = new URLSearchParams({ + itemId: itemId, + playbackPosition: + item.UserData?.PlaybackPositionTicks?.toString() ?? "", + }).toString(); + // @ts-expect-error + router.replace(`player/direct-player?${queryParams}`); return; } + const gotoItem = await getItemById(api, itemId); + if (!gotoItem) return; goToItemCommon(gotoItem); }, [goToItemCommon, api], @@ -726,8 +740,8 @@ export const Controls: FC = ({ pointerEvents={showControls ? "auto" : "none"} className={"flex flex-row w-full pt-2"} > - {!Platform.isTV && ( - + + {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( = ({ > - - )} + )} + {!Platform.isTV && - settings.defaultPlayer === VideoPlayer.VLC_4 && ( + (settings.defaultPlayer === VideoPlayer.VLC_4 || + Platform.OS === "android") && ( = ({ /> )} - - {item?.Type === "Episode" && !offline && ( + {item?.Type === "Episode" && ( { switchOnEpisodeMode(); @@ -766,7 +780,7 @@ export const Controls: FC = ({ )} - {previousItem && !offline && ( + {previousItem && ( = ({ )} - - {nextItem && !offline && ( + {nextItem && ( goToNextItem({ isAutoPlay: false })} className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' @@ -783,7 +796,6 @@ export const Controls: FC = ({ )} - {/* {mediaSource?.TranscodingUrl && ( */} = ({ color='white' /> - {/* )} */} = ({ - ({}); export const EpisodeList: React.FC = ({ item, close, goToItem }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const _insets = useSafeAreaInsets(); // Get safe area insets const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); const scrollViewRef = useRef(null); // Reference to the HorizontalScroll const scrollToIndex = (index: number) => { scrollViewRef.current?.scrollToIndex(index, 100); }; + const { offline } = useGlobalSearchParams<{ + offline: string; + }>(); + const isOffline = offline === "true"; // Set the initial season index useEffect(() => { if (item.SeriesId) { setSeasonIndexState((prev) => ({ ...prev, - [item.SeriesId ?? ""]: item.ParentIndexNumber ?? 0, + [item.ParentId ?? ""]: item.ParentIndexNumber ?? 0, })); } }, []); - const seasonIndex = seasonIndexState[item.SeriesId ?? ""]; - const [seriesItem, setSeriesItem] = useState(null); + const { getDownloadedItems } = useDownload(); + const downloadedFiles = getDownloadedItems(); - // This effect fetches the series item data/ - useEffect(() => { - if (item.SeriesId) { - getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then( - (res) => { - setSeriesItem(res); - }, - ); - } - }, [item.SeriesId]); + const seasonIndex = seasonIndexState[item.ParentId ?? ""]; const { data: seasons } = useQuery({ queryKey: ["seasons", item.SeriesId], queryFn: async () => { + if (isOffline) { + if (!item.SeriesId) return []; + const seriesEpisodes = downloadedFiles?.filter( + (f: DownloadedItem) => f.item.SeriesId === item.SeriesId, + ); + const seasonNumbers = [ + ...new Set( + seriesEpisodes + ?.map((f: DownloadedItem) => f.item.ParentIndexNumber) + .filter(Boolean), + ), + ]; + // Create fake season objects + return seasonNumbers.map((seasonNumber) => ({ + Id: seasonNumber?.toString(), + IndexNumber: seasonNumber, + Name: `Season ${seasonNumber}`, + SeriesId: item.SeriesId, + })); + } + if (!api || !user?.Id || !item.SeriesId) return []; - const response = await api.axiosInstance.get( - `${api.basePath}/Shows/${item.SeriesId}/Seasons`, - { - params: { - userId: user?.Id, - itemId: item.SeriesId, - Fields: - "ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount", - }, - headers: { - Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, - }, - }, - ); + const response = await getTvShowsApi(api).getSeasons({ + seriesId: item.SeriesId, + userId: user.Id, + fields: [ + "ItemCounts", + "PrimaryImageAspectRatio", + "CanDelete", + "MediaSourceCount", + ], + }); return response.data.Items; }, - enabled: !!api && !!user?.Id && !!item.SeasonId, + enabled: isOffline + ? !!item.SeriesId + : !!api && !!user?.Id && !!item.SeasonId, }); const selectedSeasonId: string | null = useMemo( () => - seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id, + seasons + ?.find((season: any) => season.IndexNumber === seasonIndex) + ?.Id?.toString() || null, [seasons, seasonIndex], ); - const { data: episodes } = useQuery({ + const { data: episodes, isLoading: episodesLoading } = useQuery({ queryKey: ["episodes", item.SeriesId, selectedSeasonId], queryFn: async () => { + if (isOffline) { + if (!item.SeriesId) return []; + return downloadedFiles + ?.filter( + (f: DownloadedItem) => + f.item.SeriesId === item.SeriesId && + f.item.ParentIndexNumber === seasonIndex, + ) + .map((f: DownloadedItem) => f.item); + } if (!api || !user?.Id || !item.Id || !selectedSeasonId) return []; const res = await getTvShowsApi(api).getEpisodes({ seriesId: item.SeriesId || "", @@ -112,7 +139,7 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { useEffect(() => { if (item?.Type === "Episode" && item.Id) { - const index = episodes?.findIndex((ep) => ep.Id === item.Id); + const index = episodes?.findIndex((ep: BaseItemDto) => ep.Id === item.Id); if (index !== undefined && index !== -1) { setTimeout(() => { scrollToIndex(index); @@ -150,12 +177,8 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { } }, [episodes, item.Id]); - if (!episodes) { - return ; - } - return ( - = ({ item, close, goToItem }) => { width: "100%", }} > - - {seriesItem && ( + + {seasons && seasons.length > 0 && !episodesLoading && episodes && ( { setSeasonIndexState((prev) => ({ ...prev, - [item.SeriesId ?? ""]: season.IndexNumber, + [item.ParentId ?? ""]: season.IndexNumber, })); }} /> @@ -186,64 +204,72 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { onPress={async () => { close(); }} - className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2' + className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2 ml-auto' > - ( - - { - goToItem(_item.Id); - }} + {!episodes || episodesLoading ? ( + + + + ) : ( + ( + - - - - { + goToItem(_item.Id); }} > - {_item.Name} - - - {`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`} - - - {runtimeTicksToSeconds(_item.RunTimeTicks)} + + + + + {_item.Name} + + + {`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`} + + + {runtimeTicksToSeconds(_item.RunTimeTicks)} + + + + {_item.Overview} - - - - - {_item.Overview} - - - )} - keyExtractor={(e: BaseItemDto) => e.Id ?? ""} - estimatedItemSize={200} - showsHorizontalScrollIndicator={false} - /> - + )} + keyExtractor={(e: BaseItemDto) => e.Id ?? ""} + estimatedItemSize={200} + showsHorizontalScrollIndicator={false} + /> + )} + ); }; diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx index ca1344d6..2317f36a 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -1,3 +1,4 @@ +import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client"; import { router, useLocalSearchParams } from "expo-router"; import type React from "react"; import { @@ -9,7 +10,6 @@ import { useState, } from "react"; import type { TrackInfo } from "@/modules/VlcPlayer.types"; -import { useSettings, VideoPlayer } from "@/utils/atoms/settings"; import type { Track } from "../types"; import { useControlContext } from "./ControlContext"; @@ -48,7 +48,6 @@ export const VideoProvider: React.FC = ({ }) => { const [audioTracks, setAudioTracks] = useState(null); const [subtitleTracks, setSubtitleTracks] = useState(null); - const [settings] = useSettings(); const ControlContext = useControlContext(); const isVideoLoaded = ControlContext?.isVideoLoaded; @@ -67,13 +66,17 @@ export const VideoProvider: React.FC = ({ playbackPosition: string; }>(); - const onTextBasedSubtitle = useMemo( - () => + const onTextBasedSubtitle = useMemo(() => { + return ( allSubs.find( - (s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream, - ) || subtitleIndex === "-1", - [allSubs, subtitleIndex], - ); + (s) => + s.Index?.toString() === subtitleIndex && + (s.DeliveryMethod === SubtitleDeliveryMethod.Embed || + s.DeliveryMethod === SubtitleDeliveryMethod.Hls || + s.DeliveryMethod === SubtitleDeliveryMethod.External), + ) || subtitleIndex === "-1" + ); + }, [allSubs, subtitleIndex]); const setPlayerParams = ({ chosenAudioIndex = audioIndex, @@ -92,7 +95,7 @@ export const VideoProvider: React.FC = ({ playbackPosition: playbackPosition, }).toString(); - //@ts-ignore + //@ts-expect-error router.replace(`player/direct-player?${queryParams}`); }; @@ -128,30 +131,32 @@ export const VideoProvider: React.FC = ({ useEffect(() => { const fetchTracks = async () => { if (getSubtitleTracks) { - const subtitleData = await getSubtitleTracks(); + let subtitleData = await getSubtitleTracks(); + // Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles. + if ( + mediaSource?.TranscodingUrl && + subtitleData && + subtitleData.length > 1 + ) { + subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()]; + } - // Step 1: Move external subs to the end, because VLC puts external subs at the end - const sortedSubs = allSubs.sort( - (a, b) => Number(a.IsExternal) - Number(b.IsExternal), - ); - - // Step 2: Apply VLC indexing logic - let textSubIndex = settings.defaultPlayer === VideoPlayer.VLC_4 ? 0 : 1; - const processedSubs: Track[] = sortedSubs?.map((sub) => { - // Always increment for non-transcoding subtitles - // Only increment for text-based subtitles when transcoding + let embedSubIndex = 1; + const processedSubs: Track[] = allSubs?.map((sub) => { + /** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */ const shouldIncrement = - !mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream; - const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1; - const finalIndex = shouldIncrement ? vlcIndex : (sub.Index ?? -1); - - if (shouldIncrement) textSubIndex++; + sub.DeliveryMethod === SubtitleDeliveryMethod.Embed || + sub.DeliveryMethod === SubtitleDeliveryMethod.Hls || + sub.DeliveryMethod === SubtitleDeliveryMethod.External; + /** The index of subtitle inside VLC Player Itself */ + const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1; + if (shouldIncrement) embedSubIndex++; return { name: sub.DisplayTitle || "Undefined Subtitle", index: sub.Index ?? -1, setTrack: () => shouldIncrement - ? setTrackParams("subtitle", finalIndex, sub.Index ?? -1) + ? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1) : setPlayerParams({ chosenSubtitleIndex: sub.Index?.toString(), }), diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index af3de55b..ac7501c7 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -19,7 +19,7 @@ const DropdownView = () => { ]; const router = useRouter(); - const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } = + const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } = useLocalSearchParams<{ itemId: string; audioIndex: string; @@ -27,8 +27,11 @@ const DropdownView = () => { mediaSourceId: string; bitrateValue: string; playbackPosition: string; + offline: string; }>(); + const isOffline = offline === "true"; + const changeBitrate = useCallback( (bitrate: string) => { const queryParams = new URLSearchParams({ @@ -61,32 +64,34 @@ const DropdownView = () => { collisionPadding={8} sideOffset={8} > - - - Quality - - - {BITRATES?.map((bitrate, idx: number) => ( - - changeBitrate(bitrate.value?.toString() ?? "") - } - > - - {bitrate.key} - - - ))} - - + {!isOffline && ( + + + Quality + + + {BITRATES?.map((bitrate, idx: number) => ( + + changeBitrate(bitrate.value?.toString() ?? "") + } + > + + {bitrate.key} + + + ))} + + + )} Subtitle diff --git a/hooks/useAdjacentEpisodes.ts b/hooks/useAdjacentEpisodes.ts deleted file mode 100644 index 22777836..00000000 --- a/hooks/useAdjacentEpisodes.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; -import { useAtomValue } from "jotai"; -import { useMemo } from "react"; -import { apiAtom } from "@/providers/JellyfinProvider"; - -interface AdjacentEpisodesProps { - item?: BaseItemDto | null; -} - -export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => { - const api = useAtomValue(apiAtom); - - const { data: adjacentItems } = useQuery({ - queryKey: ["adjacentItems", item?.Id, item?.SeriesId], - queryFn: async (): Promise => { - if (!api || !item || !item.SeriesId) { - return null; - } - - const res = await getTvShowsApi(api).getEpisodes({ - seriesId: item.SeriesId, - adjacentTo: item.Id, - limit: 3, - fields: ["MediaSources", "MediaStreams", "ParentId"], - }); - - return res.data.Items || null; - }, - enabled: - !!api && - !!item?.Id && - !!item?.SeriesId && - (item?.Type === "Episode" || item?.Type === "Audio"), - staleTime: 0, - }); - - const previousItem = useMemo(() => { - if (!adjacentItems || adjacentItems.length <= 1) { - return null; - } - - if (adjacentItems.length === 2) { - return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0]; - } - - return adjacentItems[0]; - }, [adjacentItems, item]); - - const nextItem = useMemo(() => { - if (!adjacentItems || adjacentItems.length <= 1) { - return null; - } - - if (adjacentItems.length === 2) { - return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1]; - } - - return adjacentItems[2]; - }, [adjacentItems, item]); - - return { previousItem, nextItem }; -}; diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts index 9705c98b..15231de4 100644 --- a/hooks/useCreditSkipper.ts +++ b/hooks/useCreditSkipper.ts @@ -1,33 +1,16 @@ -import { useQuery } from "@tanstack/react-query"; -import { useAtom } from "jotai"; import { useCallback, useEffect, useState } from "react"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; -import { writeToLog } from "@/utils/log"; +import { useSegments } from "@/utils/segments"; import { msToSeconds, secondsToMs } from "@/utils/time"; import { useHaptic } from "./useHaptic"; -interface CreditTimestamps { - Introduction: { - Start: number; - End: number; - Valid: boolean; - }; - Credits: { - Start: number; - End: number; - Valid: boolean; - }; -} - export const useCreditSkipper = ( - itemId: string | undefined, + itemId: string, currentTime: number, seek: (time: number) => void, play: () => void, isVlc = false, + isOffline = false, ) => { - const [api] = useAtom(apiAtom); const [showSkipCreditButton, setShowSkipCreditButton] = useState(false); const lightHapticFeedback = useHaptic("light"); @@ -43,52 +26,30 @@ export const useCreditSkipper = ( seek(seconds); }; - const { data: creditTimestamps } = useQuery({ - queryKey: ["creditTimestamps", itemId], - queryFn: async () => { - if (!itemId) { - return null; - } - - const res = await api?.axiosInstance.get( - `${api.basePath}/Episode/${itemId}/Timestamps`, - { - headers: getAuthHeaders(api), - }, - ); - - if (res?.status !== 200) { - return null; - } - - return res?.data; - }, - enabled: !!itemId, - retry: false, - }); + const { data: segments } = useSegments(itemId, isOffline); + const creditTimestamps = segments?.creditSegments?.[0]; useEffect(() => { if (creditTimestamps) { setShowSkipCreditButton( - currentTime > creditTimestamps.Credits.Start && - currentTime < creditTimestamps.Credits.End, + currentTime > creditTimestamps.startTime && + currentTime < creditTimestamps.endTime, ); } }, [creditTimestamps, currentTime]); const skipCredit = useCallback(() => { if (!creditTimestamps) return; - console.log(`Skipping credits to ${creditTimestamps.Credits.End}`); try { lightHapticFeedback(); - wrappedSeek(creditTimestamps.Credits.End); + wrappedSeek(creditTimestamps.endTime); setTimeout(() => { play(); }, 200); } catch (error) { - writeToLog("ERROR", "Error skipping intro", error); + console.error("Error skipping credit", error); } - }, [creditTimestamps]); + }, [creditTimestamps, lightHapticFeedback, wrappedSeek, play]); return { showSkipCreditButton, skipCredit }; }; diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts index 6338517d..732ae36c 100644 --- a/hooks/useDownloadedFileOpener.ts +++ b/hooks/useDownloadedFileOpener.ts @@ -1,41 +1,28 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import * as FileSystem from "expo-file-system"; import { useRouter } from "expo-router"; import { useCallback } from "react"; import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { writeToLog } from "@/utils/log"; -export const getDownloadedFileUrl = async (itemId: string): Promise => { - const directory = FileSystem.documentDirectory; - - if (!directory) { - throw new Error("Document directory is not available"); - } - - if (!itemId) { - throw new Error("Item ID is not available"); - } - - const files = await FileSystem.readDirectoryAsync(directory); - const path = itemId!; - const matchingFile = files.find((file) => file.startsWith(path)); - - if (!matchingFile) { - throw new Error(`No file found for item ${path}`); - } - - return `${directory}${matchingFile}`; -}; - export const useDownloadedFileOpener = () => { const router = useRouter(); const { setPlayUrl, setOfflineSettings } = usePlaySettings(); const openFile = useCallback( async (item: BaseItemDto) => { + if (!item.Id) { + writeToLog("ERROR", "Attempted to open a file without an ID."); + console.error("Attempted to open a file without an ID."); + return; + } + const queryParams = new URLSearchParams({ + itemId: item.Id, + offline: "true", + playbackPosition: + item.UserData?.PlaybackPositionTicks?.toString() ?? "0", + }); try { - // @ts-expect-error - router.push(`/player/direct-player?offline=true&itemId=${item.Id}`); + router.push(`/player/direct-player?${queryParams.toString()}`); } catch (error) { writeToLog("ERROR", "Error opening file", error); console.error("Error opening file:", error); diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts index 0ddc04d2..2653a8e3 100644 --- a/hooks/useIntroSkipper.ts +++ b/hooks/useIntroSkipper.ts @@ -1,34 +1,21 @@ -import { useQuery } from "@tanstack/react-query"; -import { useAtom } from "jotai"; import { useCallback, useEffect, useState } from "react"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; -import { writeToLog } from "@/utils/log"; +import { useSegments } from "@/utils/segments"; import { msToSeconds, secondsToMs } from "@/utils/time"; import { useHaptic } from "./useHaptic"; -interface IntroTimestamps { - EpisodeId: string; - HideSkipPromptAt: number; - IntroEnd: number; - IntroStart: number; - ShowSkipPromptAt: number; - Valid: boolean; -} - /** * Custom hook to handle skipping intros in a media player. * * @param {number} currentTime - The current playback time in seconds. */ export const useIntroSkipper = ( - itemId: string | undefined, + itemId: string, currentTime: number, seek: (ticks: number) => void, play: () => void, isVlc = false, + isOffline = false, ) => { - const [api] = useAtom(apiAtom); const [showSkipButton, setShowSkipButton] = useState(false); if (isVlc) { currentTime = msToSeconds(currentTime); @@ -43,35 +30,14 @@ export const useIntroSkipper = ( seek(seconds); }; - const { data: introTimestamps } = useQuery({ - queryKey: ["introTimestamps", itemId], - queryFn: async () => { - if (!itemId) { - return null; - } - - const res = await api?.axiosInstance.get( - `${api.basePath}/Episode/${itemId}/IntroTimestamps`, - { - headers: getAuthHeaders(api), - }, - ); - - if (res?.status !== 200) { - return null; - } - - return res?.data; - }, - enabled: !!itemId, - retry: false, - }); + const { data: segments } = useSegments(itemId, isOffline); + const introTimestamps = segments?.introSegments?.[0]; useEffect(() => { if (introTimestamps) { setShowSkipButton( - currentTime > introTimestamps.ShowSkipPromptAt && - currentTime < introTimestamps.HideSkipPromptAt, + currentTime > introTimestamps.startTime && + currentTime < introTimestamps.endTime, ); } }, [introTimestamps, currentTime]); @@ -80,14 +46,14 @@ export const useIntroSkipper = ( if (!introTimestamps) return; try { lightHapticFeedback(); - wrappedSeek(introTimestamps.IntroEnd); + wrappedSeek(introTimestamps.endTime); setTimeout(() => { play(); }, 200); } catch (error) { - writeToLog("ERROR", "Error skipping intro", error); + console.error("Error skipping intro", error); } - }, [introTimestamps]); + }, [introTimestamps, lightHapticFeedback, wrappedSeek, play]); return { showSkipButton, skipIntro }; }; diff --git a/hooks/useItemQuery.ts b/hooks/useItemQuery.ts new file mode 100644 index 00000000..81c7f41e --- /dev/null +++ b/hooks/useItemQuery.ts @@ -0,0 +1,31 @@ +import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +export const useItemQuery = (itemId: string, isOffline: boolean) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const { getDownloadedItemById } = useDownload(); + + return useQuery({ + queryKey: ["item", itemId], + queryFn: async () => { + if (isOffline) { + return getDownloadedItemById(itemId)?.item; + } + if (!api || !user || !itemId) return null; + const res = await getUserLibraryApi(api).getItem({ + itemId: itemId, + userId: user?.Id, + }); + return res.data; + }, + staleTime: 0, + refetchOnMount: true, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + networkMode: "always", + }); +}; diff --git a/hooks/useMarkAsPlayed.ts b/hooks/useMarkAsPlayed.ts index fde163a1..c789e1bd 100644 --- a/hooks/useMarkAsPlayed.ts +++ b/hooks/useMarkAsPlayed.ts @@ -1,102 +1,25 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useQueryClient } from "@tanstack/react-query"; -import { useAtom } from "jotai"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed"; -import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed"; import { useHaptic } from "./useHaptic"; +import { usePlaybackManager } from "./usePlaybackManager"; +import { useInvalidatePlaybackProgressCache } from "./useRevalidatePlaybackProgressCache"; export const useMarkAsPlayed = (items: BaseItemDto[]) => { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - const queryClient = useQueryClient(); const lightHapticFeedback = useHaptic("light"); + const { markItemPlayed, markItemUnplayed } = usePlaybackManager(); + const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache(); - const invalidateQueries = () => { - const queriesToInvalidate = [ - ["resumeItems"], - ["continueWatching"], - ["nextUp-all"], - ["nextUp"], - ["episodes"], - ["seasons"], - ["home"], - ]; - - items.forEach((item) => { - if (!item.Id) return; - queriesToInvalidate.push(["item", item.Id]); - }); - - queriesToInvalidate.forEach((queryKey) => { - queryClient.invalidateQueries({ queryKey }); - }); - }; - - const markAsPlayedStatus = async (played: boolean) => { + const toggle = async (played: boolean) => { lightHapticFeedback(); + // Process all items + await Promise.all( + items.map((item) => { + if (!item.Id) return Promise.resolve(); + return played ? markItemPlayed(item.Id) : markItemUnplayed(item.Id); + }), + ); - items.forEach((item) => { - // Optimistic update - queryClient.setQueryData( - ["item", item.Id], - (oldData: BaseItemDto | undefined) => { - if (oldData) { - return { - ...oldData, - UserData: { - ...oldData.UserData, - Played: played, - }, - }; - } - return oldData; - }, - ); - }); - - try { - // Process all items - await Promise.all( - items.map((item) => - played - ? markAsPlayed({ api, item, userId: user?.Id }) - : markAsNotPlayed({ api, itemId: item?.Id, userId: user?.Id }), - ), - ); - - // Bulk invalidate - queryClient.invalidateQueries({ - queryKey: [ - "resumeItems", - "continueWatching", - "nextUp-all", - "nextUp", - "episodes", - "seasons", - "home", - ...items.map((item) => ["item", item.Id]), - ].flat(), - }); - } catch (error) { - // Revert all optimistic updates on any failure - items.forEach((item) => { - queryClient.setQueryData( - ["item", item.Id], - (oldData: BaseItemDto | undefined) => - oldData - ? { - ...oldData, - UserData: { ...oldData.UserData, Played: played }, - } - : oldData, - ); - }); - console.error("Error updating played status:", error); - } - - invalidateQueries(); + await invalidatePlaybackProgressCache(); }; - return markAsPlayedStatus; + return toggle; }; diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts new file mode 100644 index 00000000..ea838581 --- /dev/null +++ b/hooks/usePlaybackManager.ts @@ -0,0 +1,284 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useNetInfo } from "@react-native-community/netinfo"; +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { useMemo } from "react"; +import { useDownload } from "@/providers/DownloadProvider"; +import { DownloadedItem } from "@/providers/Downloads/types"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +interface PlaybackManagerProps { + item?: BaseItemDto | null; + isOffline?: boolean; +} + +/** + * Gets adjacent items (previous/current/next) for offline mode from downloaded files + */ +const getOfflineAdjacentItems = ( + item: BaseItemDto, + downloadedFiles: DownloadedItem[], +): BaseItemDto[] | null => { + if (!item.SeriesId || !downloadedFiles) { + return null; + } + + const seriesEpisodes = downloadedFiles + .filter((f) => f.item.SeriesId === item.SeriesId) + .map((f) => f.item); + + seriesEpisodes.sort((a, b) => { + if (a.ParentIndexNumber !== b.ParentIndexNumber) { + return (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0); + } + return (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0); + }); + + const currentIndex = seriesEpisodes.findIndex((ep) => ep.Id === item.Id); + + if (currentIndex === -1) { + return null; + } + + const result: BaseItemDto[] = []; + if (currentIndex > 0) { + result.push(seriesEpisodes[currentIndex - 1]); + } + result.push(seriesEpisodes[currentIndex]); + if (currentIndex < seriesEpisodes.length - 1) { + result.push(seriesEpisodes[currentIndex + 1]); + } + return result; +}; + +/** + * A hook to manage playback state, abstracting away the complexities of + * online/offline and local/remote state management. + * + * This provides a simple facade for player components to report playback + * without needing to know the underlying details of data syncing. + */ +export const usePlaybackManager = ({ + item, + isOffline = false, +}: PlaybackManagerProps = {}) => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const netInfo = useNetInfo(); + const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } = + useDownload(); + + /** Whether the device is online. actually it's connected to the internet. */ + const isOnline = netInfo.isConnected; + + // Adjacent episodes logic + const { data: adjacentItems } = useQuery({ + queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline], + queryFn: async (): Promise => { + if (!item || !item.SeriesId) { + return null; + } + + if (isOffline) { + return getOfflineAdjacentItems(item, getDownloadedItems() || []); + } + + if (!api) { + return null; + } + + const res = await getTvShowsApi(api).getEpisodes({ + seriesId: item.SeriesId, + adjacentTo: item.Id, + limit: 3, + fields: ["MediaSources", "MediaStreams", "ParentId"], + }); + + return res.data.Items || null; + }, + enabled: + (isOffline || !!api) && + !!item?.Id && + !!item?.SeriesId && + (item?.Type === "Episode" || item?.Type === "Audio"), + staleTime: 0, + }); + + const previousItem = useMemo(() => { + if (!adjacentItems || adjacentItems.length <= 1) { + return null; + } + + if (adjacentItems.length === 2) { + return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0]; + } + + return adjacentItems[0]; + }, [adjacentItems, item]); + + /** The next item in the series */ + const nextItem = useMemo(() => { + if (!adjacentItems || adjacentItems.length <= 1) { + return null; + } + + if (adjacentItems.length === 2) { + return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1]; + } + + return adjacentItems[2]; + }, [adjacentItems, item]); + + /** + * Reports playback progress. + * + * - If offline and the item is downloaded, updates are saved locally. + * - If online and the item is downloaded, it updates locally and syncs with the server. + * - If online and streaming, it reports directly to the server. + * + * @param itemId The ID of the item. + * @param positionTicks The current playback position in ticks. + */ + const reportPlaybackProgress = async ( + itemId: string, + positionTicks: number, + metadata?: { + AudioStreamIndex: number; + SubtitleStreamIndex: number; + }, + ) => { + const localItem = getDownloadedItemById(itemId); + + // Handle local state update for downloaded items + if (localItem) { + const isItemConsideredPlayed = + (localItem.item.UserData?.PlayedPercentage ?? 0) > 90; + updateDownloadedItem(itemId, { + ...localItem, + item: { + ...localItem.item, + UserData: { + ...localItem.item.UserData, + PlaybackPositionTicks: isItemConsideredPlayed + ? 0 + : Math.floor(positionTicks), + Played: isItemConsideredPlayed, + LastPlayedDate: new Date().toISOString(), + PlayedPercentage: isItemConsideredPlayed + ? 0 + : (positionTicks / localItem.item.RunTimeTicks!) * 100, + }, + }, + }); + } + + // Handle remote state update if online + if (isOnline && api) { + try { + await getPlaystateApi(api).reportPlaybackProgress({ + playbackProgressInfo: { + ItemId: itemId, + PositionTicks: Math.floor(positionTicks), + ...(metadata && { AudioStreamIndex: metadata.AudioStreamIndex }), + ...(metadata && { + SubtitleStreamIndex: metadata.SubtitleStreamIndex, + }), + }, + }); + } catch (error) { + console.error("Failed to report playback progress", error); + } + } + }; + + /** + * Marks an item as played. + * + * - If offline and downloaded, it marks as played locally. + * - If online, it marks as played on the server and syncs the state back to the local item if it exists. + * + * @param itemId The ID of the item. + */ + const markItemPlayed = async (itemId: string) => { + const localItem = getDownloadedItemById(itemId); + + // Handle local state update for downloaded items + if (localItem) { + updateDownloadedItem(itemId, { + ...localItem, + item: { + ...localItem.item, + UserData: { + ...localItem.item.UserData, + Played: true, + PlaybackPositionTicks: 0, + PlayedPercentage: 0, + LastPlayedDate: new Date().toISOString(), + }, + }, + }); + } + + // Handle remote state update if online + if (isOnline && api && user) { + try { + await getPlaystateApi(api).markPlayedItem({ + itemId, + userId: user.Id, + }); + } catch (error) { + console.error("Failed to mark item as played on server", error); + } + } + }; + + /** + * Marks an item as unplayed. + * + * - If offline and downloaded, it marks as unplayed locally. + * - If online, it marks as unplayed on the server and syncs the state back to the local item if it exists. + * + * @param itemId The ID of the item. + */ + const markItemUnplayed = async (itemId: string) => { + const localItem = getDownloadedItemById(itemId); + + // Handle local state update for downloaded items + if (localItem) { + updateDownloadedItem(itemId, { + ...localItem, + item: { + ...localItem.item, + UserData: { + ...localItem.item.UserData, + Played: false, + PlaybackPositionTicks: 0, + PlayedPercentage: 0, + LastPlayedDate: new Date().toISOString(), // Keep track of when it was marked unplayed + }, + }, + }); + } + + // Handle remote state update if online + if (isOnline && api && user) { + try { + await getPlaystateApi(api).markUnplayedItem({ + itemId, + userId: user.Id, + }); + } catch (error) { + console.error("Failed to mark item as unplayed on server", error); + } + } + }; + + return { + reportPlaybackProgress, + markItemPlayed, + markItemUnplayed, + previousItem, + nextItem, + }; +}; diff --git a/hooks/useRevalidatePlaybackProgressCache.ts b/hooks/useRevalidatePlaybackProgressCache.ts index 9cda914d..e10202f3 100644 --- a/hooks/useRevalidatePlaybackProgressCache.ts +++ b/hooks/useRevalidatePlaybackProgressCache.ts @@ -1,10 +1,14 @@ import { useQueryClient } from "@tanstack/react-query"; +import { useDownload } from "@/providers/DownloadProvider"; +import { useTwoWaySync } from "./useTwoWaySync"; /** * useRevalidatePlaybackProgressCache invalidates queries related to playback progress. */ export function useInvalidatePlaybackProgressCache() { const queryClient = useQueryClient(); + const { getDownloadedItems } = useDownload(); + const { syncPlaybackState } = useTwoWaySync(); const revalidate = async () => { // List of all the queries to invalidate @@ -17,11 +21,34 @@ export function useInvalidatePlaybackProgressCache() { ["episodes"], ["seasons"], ["home"], + ["downloadedItems"], ]; - // Invalidate each query - for (const queryKey of queriesToInvalidate) { - await queryClient.invalidateQueries({ queryKey }); + // We Invalidate all the queries to the latest server versions + await Promise.all( + queriesToInvalidate.map((queryKey) => + queryClient.invalidateQueries({ queryKey }), + ), + ); + + const downloadedFiles = getDownloadedItems(); + // Sync playback state for downloaded items + if (downloadedFiles) { + // We sync the playback state for the downloaded items + const syncResults = await Promise.all( + downloadedFiles.map((downloadedItem) => + syncPlaybackState(downloadedItem.item.Id!), + ), + ); + // We invalidate the queries again in case we have updated a server's playback progress. + const shouldInvalidate = syncResults.some((result) => result); + + console.log("shouldInvalidate", shouldInvalidate); + if (shouldInvalidate) { + queriesToInvalidate.map((queryKey) => + queryClient.invalidateQueries({ queryKey }), + ); + } } }; diff --git a/hooks/useTrickplay.ts b/hooks/useTrickplay.ts index c0eb6f9c..162fe144 100644 --- a/hooks/useTrickplay.ts +++ b/hooks/useTrickplay.ts @@ -1,11 +1,80 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { Image } from "expo-image"; -import { useAtom } from "jotai"; +import { useGlobalSearchParams } from "expo-router"; import { useCallback, useMemo, useRef, useState } from "react"; +import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom } from "@/providers/JellyfinProvider"; +import { store } from "@/utils/store"; import { ticksToMs } from "@/utils/time"; -interface TrickplayData { +interface TrickplayUrl { + x: number; + y: number; + url: string; +} + +/** Hook to handle trickplay logic for a given item. */ +export const useTrickplay = (item: BaseItemDto) => { + const [trickPlayUrl, setTrickPlayUrl] = useState(null); + const { getDownloadedItemById } = useDownload(); + const lastCalculationTime = useRef(0); + const throttleDelay = 200; + const isOffline = useGlobalSearchParams().offline === "true"; + const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]); + + /** Generates the trickplay URL for the given item and sheet index. + * We change between offline and online trickplay URLs depending on the state of the app. */ + const getTrickplayUrl = useCallback( + (item: BaseItemDto, sheetIndex: number) => { + // If we are offline, we can use the downloaded item's trickplay data path + const downloadedItem = getDownloadedItemById(item.Id!); + if (isOffline && downloadedItem?.trickPlayData?.path) { + return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`; + } + return generateTrickplayUrl(item, sheetIndex); + }, + [trickplayInfo], + ); + + /** Calculates the trickplay URL for the current progress. */ + const calculateTrickplayUrl = useCallback( + (progress: number) => { + const now = Date.now(); + if ( + !trickplayInfo || + !item.Id || + now - lastCalculationTime.current < throttleDelay + ) + return; + lastCalculationTime.current = now; + const { sheetIndex, x, y } = calculateTrickplayTile( + progress, + trickplayInfo, + ); + const url = getTrickplayUrl(item, sheetIndex); + if (url) setTrickPlayUrl({ x, y, url }); + }, + [trickplayInfo, item, throttleDelay, getTrickplayUrl], + ); + + /** Prefetches all the trickplay images for the item. */ + const prefetchAllTrickplayImages = useCallback(() => { + if (!trickplayInfo || !item.Id) return; + for (let index = 0; index < trickplayInfo.totalImageSheets; index++) { + const url = getTrickplayUrl(item, index); + if (url) Image.prefetch(url); + } + }, [trickplayInfo, item, getTrickplayUrl]); + + return { + trickPlayUrl, + calculateTrickplayUrl, + prefetchAllTrickplayImages, + trickplayInfo, + }; +}; + +export interface TrickplayData { Interval?: number; TileWidth?: number; TileHeight?: number; @@ -14,136 +83,93 @@ interface TrickplayData { ThumbnailCount?: number; } -interface TrickplayUrl { - x: number; - y: number; - url: string; +export interface TrickplayInfo { + resolution: string; + aspectRatio: number; + data: TrickplayData; + totalImageSheets: number; } -export const useTrickplay = (item: BaseItemDto, enabled = true) => { - const [api] = useAtom(apiAtom); - const [trickPlayUrl, setTrickPlayUrl] = useState(null); - const lastCalculationTime = useRef(0); - const throttleDelay = 200; // 200ms throttle +/** Generates a trickplay URL based on the item, resolution, and sheet index. */ +export const generateTrickplayUrl = (item: BaseItemDto, sheetIndex: number) => { + const api = store.get(apiAtom); + const resolution = getTrickplayInfo(item)?.resolution; + if (!resolution || !api) return null; + return `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${sheetIndex}.jpg?api_key=${api.accessToken}`; +}; - const trickplayInfo = useMemo(() => { - if (!enabled || !item.Id || !item.Trickplay) { - return null; - } +/** + * Parses the trickplay metadata from a BaseItemDto. + * @param item The Jellyfin media item. + * @returns Parsed trickplay information or null if not available. + */ +export const getTrickplayInfo = (item: BaseItemDto): TrickplayInfo | null => { + if (!item.Id || !item.Trickplay) return null; - const mediaSourceId = item.Id; - const trickplayData: Record | undefined = - item.Trickplay[mediaSourceId]; + const mediaSourceId = item.Id; + const trickplayDataForSource = item.Trickplay[mediaSourceId]; - if (!trickplayData) { - return null; - } + if (!trickplayDataForSource) { + return null; + } - // Get the first available resolution - const firstResolution = Object.keys(trickplayData)[0]; - return firstResolution - ? { - resolution: firstResolution, - aspectRatio: - trickplayData[firstResolution].Width! / - trickplayData[firstResolution].Height!, - data: trickplayData[firstResolution], - } - : null; - }, [item, enabled]); + const firstResolution = Object.keys(trickplayDataForSource)[0]; + if (!firstResolution) { + return null; + } - // Takes in ticks. - const calculateTrickplayUrl = useCallback( - (progress: number) => { - if (!enabled) { - return null; - } + const data = trickplayDataForSource[firstResolution]; + const { Interval, TileWidth, TileHeight, Width, Height } = data; - const now = Date.now(); - if (now - lastCalculationTime.current < throttleDelay) { - return null; - } - lastCalculationTime.current = now; + if ( + !Interval || + !TileWidth || + !TileHeight || + !Width || + !Height || + !item.RunTimeTicks + ) { + return null; + } - if (!trickplayInfo || !api || !item.Id) { - return null; - } - - const { data, resolution } = trickplayInfo; - const { Interval, TileWidth, TileHeight, Width, Height } = data; - - if ( - !Interval || - !TileWidth || - !TileHeight || - !resolution || - !Width || - !Height - ) { - throw new Error("Invalid trickplay data"); - } - - const currentTimeMs = Math.max(0, ticksToMs(progress)); - const currentTile = Math.floor(currentTimeMs / Interval); - - const tileSize = TileWidth * TileHeight; - const tileOffset = currentTile % tileSize; - const index = Math.floor(currentTile / tileSize); - - const tileOffsetX = tileOffset % TileWidth; - const tileOffsetY = Math.floor(tileOffset / TileWidth); - - const newTrickPlayUrl = { - x: tileOffsetX, - y: tileOffsetY, - url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`, - }; - - setTrickPlayUrl(newTrickPlayUrl); - return newTrickPlayUrl; - }, - [trickplayInfo, item, api, enabled], - ); - - const prefetchAllTrickplayImages = useCallback(() => { - if (!api || !enabled || !trickplayInfo || !item.Id || !item.RunTimeTicks) { - return; - } - - const { data, resolution } = trickplayInfo; - const { Interval, TileWidth, TileHeight, Width, Height } = data; - - if ( - !Interval || - !TileWidth || - !TileHeight || - !resolution || - !Width || - !Height - ) { - throw new Error("Invalid trickplay data"); - } - - // Calculate tiles per sheet - const tilesPerRow = TileWidth; - const tilesPerColumn = TileHeight; - const tilesPerSheet = tilesPerRow * tilesPerColumn; - const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval); - const totalIndexes = Math.ceil(totalTiles / tilesPerSheet); - - // Prefetch all trickplay images - for (let index = 0; index < totalIndexes; index++) { - const url = `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`; - Image.prefetch(url); - } - }, [trickplayInfo, item, api, enabled]); + const tilesPerSheet = TileWidth * TileHeight; + const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval); + const totalImageSheets = Math.ceil(totalTiles / tilesPerSheet); return { - trickPlayUrl: enabled ? trickPlayUrl : null, - calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null, - prefetchAllTrickplayImages: enabled - ? prefetchAllTrickplayImages - : () => null, - trickplayInfo: enabled ? trickplayInfo : null, + resolution: firstResolution, + aspectRatio: Width / Height, + data, + totalImageSheets, }; }; + +/** + * Calculates the specific image sheet and tile offset for a given time. + * @param progressTicks The current playback time in ticks. + * @param trickplayInfo The parsed trickplay information object. + * @returns An object with the image sheet index, and the X/Y coordinates for the tile. + */ +const calculateTrickplayTile = ( + progressTicks: number, + trickplayInfo: TrickplayInfo, +) => { + const { data } = trickplayInfo; + const { Interval, TileWidth, TileHeight } = data; + + if (!Interval || !TileWidth || !TileHeight) { + throw new Error("Invalid trickplay data provided to calculateTile"); + } + + const currentTimeMs = Math.max(0, ticksToMs(progressTicks)); + const currentTile = Math.floor(currentTimeMs / Interval); + + const tilesPerSheet = TileWidth * TileHeight; + const sheetIndex = Math.floor(currentTile / tilesPerSheet); + const tileIndexInSheet = currentTile % tilesPerSheet; + + const x = tileIndexInSheet % TileWidth; + const y = Math.floor(tileIndexInSheet / TileWidth); + + return { sheetIndex, x, y }; +}; diff --git a/hooks/useTwoWaySync.ts b/hooks/useTwoWaySync.ts new file mode 100644 index 00000000..c31bf7ea --- /dev/null +++ b/hooks/useTwoWaySync.ts @@ -0,0 +1,86 @@ +import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { useNetInfo } from "@react-native-community/netinfo"; +import { useAtomValue } from "jotai"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom, userAtom } from "../providers/JellyfinProvider"; + +/** + * This hook is used to sync the playback state of a downloaded item with the server + * when the application comes back online after being used offline. + */ +export const useTwoWaySync = () => { + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const netInfo = useNetInfo(); + const { getDownloadedItemById, updateDownloadedItem } = useDownload(); + + /** + * Syncs the playback state of an offline item with the server. + * It determines if the local or remote state is more recent and applies the necessary update. + * + * @returns A Promise indicating whether a server update was made (true) or not (false). + */ + const syncPlaybackState = async (itemId: string): Promise => { + if (!api || !user || !netInfo.isConnected) { + // Cannot sync if offline or not logged in + return false; + } + + const localItem = getDownloadedItemById(itemId); + if (!localItem) return false; + + const remoteItem = ( + await getUserLibraryApi(api).getItem({ itemId, userId: user.Id }) + ).data; + if (!remoteItem) return false; + + const localLastPlayed = localItem.item.UserData?.LastPlayedDate + ? new Date(localItem.item.UserData.LastPlayedDate) + : new Date(0); + const remoteLastPlayed = remoteItem.UserData?.LastPlayedDate + ? new Date(remoteItem.UserData.LastPlayedDate) + : new Date(0); + + // If the remote item has been played more recently, we take the server's version as the source of truth. + if (remoteLastPlayed > localLastPlayed) { + updateDownloadedItem(itemId, { + ...localItem, + item: { + ...localItem.item, + UserData: { + ...localItem.item.UserData, + LastPlayedDate: remoteItem.UserData?.LastPlayedDate, + PlaybackPositionTicks: remoteItem.UserData?.PlaybackPositionTicks, + Played: remoteItem.UserData?.Played, + PlayedPercentage: remoteItem.UserData?.PlayedPercentage, + }, + }, + }); + return false; + } else if (remoteLastPlayed < localLastPlayed) { + // Since we're this is the source of truth, essentially need to make sure the played status matches the local item. + try { + await getItemsApi(api).updateItemUserData({ + itemId: localItem.item.Id!, + userId: user.Id, + updateUserItemDataDto: { + Played: localItem.item.UserData?.Played, + PlaybackPositionTicks: + localItem.item.UserData?.PlaybackPositionTicks, + PlayedPercentage: localItem.item.UserData?.PlayedPercentage, + LastPlayedDate: localItem.item.UserData?.LastPlayedDate, + }, + }); + } catch (error) { + console.error( + "Failed to update item user data during syncPlaybackState:", + error, + ); + } + return true; + } + return false; + }; + + return { syncPlaybackState }; +}; diff --git a/modules/VlcPlayer.types.ts b/modules/VlcPlayer.types.ts index 85de4348..9bdd0afd 100644 --- a/modules/VlcPlayer.types.ts +++ b/modules/VlcPlayer.types.ts @@ -41,10 +41,10 @@ export type VlcPlayerSource = { type?: string; isNetwork?: boolean; autoplay?: boolean; - externalSubtitles: { name: string; DeliveryUrl: string }[]; + startPosition?: number; + externalSubtitles?: { name: string; DeliveryUrl: string }[]; initOptions?: any[]; mediaOptions?: { [key: string]: any }; - startPosition?: number; }; export type TrackInfo = { @@ -94,5 +94,5 @@ export interface VlcPlayerViewRef { getChapters: () => Promise; setVideoCropGeometry: (geometry: string | null) => Promise; getVideoCropGeometry: () => Promise; - setSubtitleURL: (url: string, name: string) => Promise; + setSubtitleURL: (url: string) => Promise; } diff --git a/modules/VlcPlayerView.tsx b/modules/VlcPlayerView.tsx index aedceb63..24c0d0ff 100644 --- a/modules/VlcPlayerView.tsx +++ b/modules/VlcPlayerView.tsx @@ -1,7 +1,6 @@ import { requireNativeViewManager } from "expo-modules-core"; import * as React from "react"; -import { Platform, ViewStyle } from "react-native"; -import { useSettings, VideoPlayer } from "@/utils/atoms/settings"; +import { ViewStyle } from "react-native"; import type { VlcPlayerSource, VlcPlayerViewProps, @@ -13,20 +12,10 @@ interface NativeViewRef extends VlcPlayerViewRef { } const VLCViewManager = requireNativeViewManager("VlcPlayer"); -const VLC3ViewManager = requireNativeViewManager("VlcPlayer3"); // Create a forwarded ref version of the native view const NativeView = React.forwardRef( (props, ref) => { - const [settings] = useSettings(); - - if (Platform.OS === "ios" || Platform.isTVOS) { - if (settings.defaultPlayer === VideoPlayer.VLC_3) { - console.log("[Apple] Using Vlc Player 3"); - return ; - } - } - console.log("Using default Vlc Player"); return ; }, ); @@ -94,8 +83,8 @@ const VlcPlayerView = React.forwardRef( const geometry = await nativeRef.current?.getVideoCropGeometry(); return geometry ?? null; }, - setSubtitleURL: async (url: string, name: string) => { - await nativeRef.current?.setSubtitleURL(url, name); + setSubtitleURL: async (url: string) => { + await nativeRef.current?.setSubtitleURL(url); }, })); diff --git a/modules/vlc-player-3/expo-module.config.json b/modules/vlc-player-3/expo-module.config.json deleted file mode 100644 index 1e6766d7..00000000 --- a/modules/vlc-player-3/expo-module.config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "platforms": ["ios", "tvos"], - "ios": { - "modules": ["VlcPlayer3Module"] - } -} diff --git a/modules/vlc-player-3/ios/VlcPlayer3View.swift b/modules/vlc-player-3/ios/VlcPlayer3View.swift deleted file mode 100644 index 50882734..00000000 --- a/modules/vlc-player-3/ios/VlcPlayer3View.swift +++ /dev/null @@ -1,392 +0,0 @@ -import ExpoModulesCore - -#if os(tvOS) - import TVVLCKit -#else - import MobileVLCKit -#endif - -class VlcPlayer3View: ExpoView { - private var mediaPlayer: VLCMediaPlayer? - private var videoView: UIView? - private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second - private var isPaused: Bool = false - private var currentGeometryCString: [CChar]? - private var lastReportedState: VLCMediaPlayerState? - private var lastReportedIsPlaying: Bool? - private var customSubtitles: [(internalName: String, originalName: String)] = [] - private var startPosition: Int32 = 0 - private var externalSubtitles: [[String: String]]? - private var externalTrack: [String: String]? - private var progressTimer: DispatchSourceTimer? - private var isStopping: Bool = false // Define isStopping here - private var lastProgressCall = Date().timeIntervalSince1970 - var hasSource = false - - // MARK: - Initialization - - required init(appContext: AppContext? = nil) { - super.init(appContext: appContext) - setupView() - setupNotifications() - } - - // MARK: - Setup - - private func setupView() { - DispatchQueue.main.async { - self.backgroundColor = .black - self.videoView = UIView() - self.videoView?.translatesAutoresizingMaskIntoConstraints = false - - if let videoView = self.videoView { - self.addSubview(videoView) - NSLayoutConstraint.activate([ - videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor), - videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor), - videoView.topAnchor.constraint(equalTo: self.topAnchor), - videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor), - ]) - } - } - } - - private func setupNotifications() { - NotificationCenter.default.addObserver( - self, selector: #selector(applicationWillResignActive), - name: UIApplication.willResignActiveNotification, object: nil) - NotificationCenter.default.addObserver( - self, selector: #selector(applicationDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, object: nil) - } - - // MARK: - Public Methods - func startPictureInPicture() {} - - @objc func play() { - self.mediaPlayer?.play() - self.isPaused = false - print("Play") - } - - @objc func pause() { - self.mediaPlayer?.pause() - self.isPaused = true - } - - @objc func seekTo(_ time: Int32) { - guard let player = self.mediaPlayer else { return } - - let wasPlaying = player.isPlaying - if wasPlaying { - self.pause() - } - - if let duration = player.media?.length.intValue { - print("Seeking to time: \(time) Video Duration \(duration)") - - // If the specified time is greater than the duration, seek to the end - let seekTime = time > duration ? duration - 1000 : time - player.time = VLCTime(int: seekTime) - - if wasPlaying { - self.play() - } - self.updatePlayerState() - } else { - print("Error: Unable to retrieve video duration") - } - } - - @objc func setSource(_ source: [String: Any]) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - if self.hasSource { - return - } - - let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:] - self.externalTrack = source["externalTrack"] as? [String: String] - var initOptions = source["initOptions"] as? [Any] ?? [] - self.startPosition = source["startPosition"] as? Int32 ?? 0 - self.externalSubtitles = source["externalSubtitles"] as? [[String: String]] - initOptions.append("--start-time=\(self.startPosition)") - - guard let uri = source["uri"] as? String, !uri.isEmpty else { - print("Error: Invalid or empty URI") - self.onVideoError?(["error": "Invalid or empty URI"]) - return - } - - let autoplay = source["autoplay"] as? Bool ?? false - let isNetwork = source["isNetwork"] as? Bool ?? false - - self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()]) - self.mediaPlayer = VLCMediaPlayer(options: initOptions) - self.mediaPlayer?.delegate = self - self.mediaPlayer?.drawable = self.videoView - self.mediaPlayer?.scaleFactor = 0 - - let media: VLCMedia - if isNetwork { - print("Loading network file: \(uri)") - media = VLCMedia(url: URL(string: uri)!) - } else { - print("Loading local file: \(uri)") - if uri.starts(with: "file://"), let url = URL(string: uri) { - media = VLCMedia(url: url) - } else { - media = VLCMedia(path: uri) - } - } - - print("Debug: Media options: \(mediaOptions)") - media.addOptions(mediaOptions) - - self.mediaPlayer?.media = media - self.setInitialExternalSubtitles() - self.hasSource = true - if autoplay { - print("Playing...") - self.play() - } - } - } - - @objc func setAudioTrack(_ trackIndex: Int) { - self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex) - } - - @objc func getAudioTracks() -> [[String: Any]]? { - guard let trackNames = mediaPlayer?.audioTrackNames, - let trackIndexes = mediaPlayer?.audioTrackIndexes - else { - return nil - } - - return zip(trackNames, trackIndexes).map { name, index in - return ["name": name, "index": index] - } - } - - @objc func setSubtitleTrack(_ trackIndex: Int) { - print("Debug: Attempting to set subtitle track to index: \(trackIndex)") - self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex) - print( - "Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)" - ) - } - - @objc func setSubtitleURL(_ subtitleURL: String, name: String) { - guard let url = URL(string: subtitleURL) else { - print("Error: Invalid subtitle URL") - return - } - - let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: false) - if let result = result { - let internalName = "Track \(self.customSubtitles.count)" - print("Subtitle added with result: \(result) \(internalName)") - self.customSubtitles.append((internalName: internalName, originalName: name)) - } else { - print("Failed to add subtitle") - } - } - - private func setInitialExternalSubtitles() { - if let externalSubtitles = self.externalSubtitles { - for subtitle in externalSubtitles { - if let subtitleName = subtitle["name"], - let subtitleURL = subtitle["DeliveryUrl"] - { - print("Setting external subtitle: \(subtitleName) \(subtitleURL)") - self.setSubtitleURL(subtitleURL, name: subtitleName) - } - } - } - } - - @objc func getSubtitleTracks() -> [[String: Any]]? { - guard let mediaPlayer = self.mediaPlayer else { - return nil - } - - let count = mediaPlayer.numberOfSubtitlesTracks - print("Debug: Number of subtitle tracks: \(count)") - - guard count > 0 else { - return nil - } - - var tracks: [[String: Any]] = [] - - if let names = mediaPlayer.videoSubTitlesNames as? [String], - let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] - { - for (index, name) in zip(indexes, names) { - if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) { - tracks.append(["name": customSubtitle.originalName, "index": index.intValue]) - } else { - tracks.append(["name": name, "index": index.intValue]) - } - } - } - - print("Debug: Subtitle tracks: \(tracks)") - return tracks - } - - @objc func stop(completion: (() -> Void)? = nil) { - guard !isStopping else { - completion?() - return - } - isStopping = true - - // If we're not on the main thread, dispatch to main thread - if !Thread.isMainThread { - DispatchQueue.main.async { [weak self] in - self?.performStop(completion: completion) - } - } else { - performStop(completion: completion) - } - } - - // MARK: - Private Methods - - @objc private func applicationWillResignActive() { - - } - - @objc private func applicationDidBecomeActive() { - - } - - private func performStop(completion: (() -> Void)? = nil) { - // Stop the media player - mediaPlayer?.stop() - - // Remove observer - NotificationCenter.default.removeObserver(self) - - // Clear the video view - videoView?.removeFromSuperview() - videoView = nil - - // Release the media player - mediaPlayer?.delegate = nil - mediaPlayer = nil - - isStopping = false - completion?() - } - - private func updateVideoProgress() { - guard let player = self.mediaPlayer else { return } - - let currentTimeMs = player.time.intValue - let durationMs = player.media?.length.intValue ?? 0 - - print("Debug: Current time: \(currentTimeMs)") - if currentTimeMs >= 0 && currentTimeMs < durationMs { - self.onVideoProgress?([ - "currentTime": currentTimeMs, - "duration": durationMs, - ]) - } - } - - // MARK: - Expo Events - - @objc var onPlaybackStateChanged: RCTDirectEventBlock? - @objc var onVideoLoadStart: RCTDirectEventBlock? - @objc var onVideoStateChange: RCTDirectEventBlock? - @objc var onVideoProgress: RCTDirectEventBlock? - @objc var onVideoLoadEnd: RCTDirectEventBlock? - @objc var onVideoError: RCTDirectEventBlock? - @objc var onPipStarted: RCTDirectEventBlock? - - // MARK: - Deinitialization - - deinit { - performStop() - } -} - -extension VlcPlayer3View: VLCMediaPlayerDelegate { - func mediaPlayerTimeChanged(_ aNotification: Notification) { - // self?.updateVideoProgress() - let timeNow = Date().timeIntervalSince1970 - if timeNow - lastProgressCall >= 1 { - lastProgressCall = timeNow - updateVideoProgress() - } - } - - func mediaPlayerStateChanged(_ aNotification: Notification) { - self.updatePlayerState() - } - - private func updatePlayerState() { - guard let player = self.mediaPlayer else { return } - let currentState = player.state - - var stateInfo: [String: Any] = [ - "target": self.reactTag ?? NSNull(), - "currentTime": player.time.intValue, - "duration": player.media?.length.intValue ?? 0, - "error": false, - ] - - if player.isPlaying { - stateInfo["isPlaying"] = true - stateInfo["isBuffering"] = false - stateInfo["state"] = "Playing" - } else { - stateInfo["isPlaying"] = false - stateInfo["state"] = "Paused" - } - - if player.state == VLCMediaPlayerState.buffering { - stateInfo["isBuffering"] = true - stateInfo["state"] = "Buffering" - } else if player.state == VLCMediaPlayerState.error { - print("player.state ~ error") - stateInfo["state"] = "Error" - self.onVideoLoadEnd?(stateInfo) - } else if player.state == VLCMediaPlayerState.opening { - print("player.state ~ opening") - stateInfo["state"] = "Opening" - } - - if self.lastReportedState != currentState - || self.lastReportedIsPlaying != player.isPlaying - { - self.lastReportedState = currentState - self.lastReportedIsPlaying = player.isPlaying - self.onVideoStateChange?(stateInfo) - } - - } -} - -extension VlcPlayer3View: VLCMediaDelegate { - // Implement VLCMediaDelegate methods if needed -} - -extension VLCMediaPlayerState { - var description: String { - switch self { - case .opening: return "Opening" - case .buffering: return "Buffering" - case .playing: return "Playing" - case .paused: return "Paused" - case .stopped: return "Stopped" - case .ended: return "Ended" - case .error: return "Error" - case .esAdded: return "ESAdded" - @unknown default: return "Unknown" - } - } -} diff --git a/modules/vlc-player-4/expo-module.config.json b/modules/vlc-player-4/expo-module.config.json new file mode 100644 index 00000000..494e40db --- /dev/null +++ b/modules/vlc-player-4/expo-module.config.json @@ -0,0 +1,7 @@ +{ + "platforms": ["ios", "tvos"], + "ios": { + "modules": ["VlcPlayer4Module"], + "appDelegateSubscribers": ["AppLifecycleDelegate"] + } +} diff --git a/modules/vlc-player/ios/AppLifecycleDelegate.swift b/modules/vlc-player-4/ios/AppLifecycleDelegate.swift similarity index 99% rename from modules/vlc-player/ios/AppLifecycleDelegate.swift rename to modules/vlc-player-4/ios/AppLifecycleDelegate.swift index d5069b48..916de305 100644 --- a/modules/vlc-player/ios/AppLifecycleDelegate.swift +++ b/modules/vlc-player-4/ios/AppLifecycleDelegate.swift @@ -29,4 +29,4 @@ public class AppLifecycleDelegate: ExpoAppDelegateSubscriber { public func applicationWillTerminate(_ application: UIApplication) { // The app is about to terminate. } -} +} \ No newline at end of file diff --git a/modules/vlc-player/ios/VLCManager.swift b/modules/vlc-player-4/ios/VLCManager.swift similarity index 100% rename from modules/vlc-player/ios/VLCManager.swift rename to modules/vlc-player-4/ios/VLCManager.swift diff --git a/modules/vlc-player-3/ios/VlcPlayer3.podspec b/modules/vlc-player-4/ios/VlcPlayer4.podspec similarity index 70% rename from modules/vlc-player-3/ios/VlcPlayer3.podspec rename to modules/vlc-player-4/ios/VlcPlayer4.podspec index 15274a12..fe47f77f 100644 --- a/modules/vlc-player-3/ios/VlcPlayer3.podspec +++ b/modules/vlc-player-4/ios/VlcPlayer4.podspec @@ -1,23 +1,22 @@ Pod::Spec.new do |s| - s.name = 'VlcPlayer3' - s.version = '3.6.1b1' + s.name = 'VlcPlayer4' + s.version = '4.0.0a10' s.summary = 'A sample project summary' s.description = 'A sample project description' s.author = '' s.homepage = 'https://docs.expo.dev/modules/' - s.platforms = { :ios => '13.4', :tvos => '13.4' } + s.platforms = { :ios => '13.4', :tvos => '16' } s.source = { git: '' } s.static_framework = true s.dependency 'ExpoModulesCore' - s.ios.dependency 'MobileVLCKit', s.version - s.tvos.dependency 'TVVLCKit', s.version + s.ios.dependency 'VLCKit', s.version + s.tvos.dependency 'VLCKit', s.version # Swift/Objective-C compatibility 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/vlc-player-3/ios/VlcPlayer3Module.swift b/modules/vlc-player-4/ios/VlcPlayer4Module.swift similarity index 66% rename from modules/vlc-player-3/ios/VlcPlayer3Module.swift rename to modules/vlc-player-4/ios/VlcPlayer4Module.swift index c0e32606..6010b156 100644 --- a/modules/vlc-player-3/ios/VlcPlayer3Module.swift +++ b/modules/vlc-player-4/ios/VlcPlayer4Module.swift @@ -1,14 +1,14 @@ import ExpoModulesCore -public class VlcPlayer3Module: Module { +public class VlcPlayer4Module: Module { public func definition() -> ModuleDefinition { - Name("VlcPlayer3") - View(VlcPlayer3View.self) { - Prop("source") { (view: VlcPlayer3View, source: [String: Any]) in + Name("VlcPlayer4") + View(VlcPlayer4View.self) { + Prop("source") { (view: VlcPlayer4View, source: [String: Any]) in view.setSource(source) } - Prop("paused") { (view: VlcPlayer3View, paused: Bool) in + Prop("paused") { (view: VlcPlayer4View, paused: Bool) in if paused { view.pause() } else { @@ -26,44 +26,44 @@ public class VlcPlayer3Module: Module { "onPipStarted" ) - AsyncFunction("startPictureInPicture") { (view: VlcPlayer3View) in + AsyncFunction("startPictureInPicture") { (view: VlcPlayer4View) in view.startPictureInPicture() } - AsyncFunction("play") { (view: VlcPlayer3View) in + AsyncFunction("play") { (view: VlcPlayer4View) in view.play() } - AsyncFunction("pause") { (view: VlcPlayer3View) in + AsyncFunction("pause") { (view: VlcPlayer4View) in view.pause() } - AsyncFunction("stop") { (view: VlcPlayer3View) in + AsyncFunction("stop") { (view: VlcPlayer4View) in view.stop() } - AsyncFunction("seekTo") { (view: VlcPlayer3View, time: Int32) in + AsyncFunction("seekTo") { (view: VlcPlayer4View, time: Int32) in view.seekTo(time) } - AsyncFunction("setAudioTrack") { (view: VlcPlayer3View, trackIndex: Int) in + AsyncFunction("setAudioTrack") { (view: VlcPlayer4View, trackIndex: Int) in view.setAudioTrack(trackIndex) } - AsyncFunction("getAudioTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in + AsyncFunction("getAudioTracks") { (view: VlcPlayer4View) -> [[String: Any]]? in return view.getAudioTracks() } - AsyncFunction("setSubtitleTrack") { (view: VlcPlayer3View, trackIndex: Int) in + AsyncFunction("setSubtitleTrack") { (view: VlcPlayer4View, trackIndex: Int) in view.setSubtitleTrack(trackIndex) } - AsyncFunction("getSubtitleTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in + AsyncFunction("getSubtitleTracks") { (view: VlcPlayer4View) -> [[String: Any]]? in return view.getSubtitleTracks() } AsyncFunction("setSubtitleURL") { - (view: VlcPlayer3View, url: String, name: String) in + (view: VlcPlayer4View, url: String, name: String) in view.setSubtitleURL(url, name: name) } } diff --git a/modules/vlc-player-4/ios/VlcPlayer4View.swift b/modules/vlc-player-4/ios/VlcPlayer4View.swift new file mode 100644 index 00000000..66c9a071 --- /dev/null +++ b/modules/vlc-player-4/ios/VlcPlayer4View.swift @@ -0,0 +1,507 @@ +import ExpoModulesCore +import UIKit +import VLCKit +import os + +public class VLCPlayerView: UIView { + func setupView(parent: UIView) { + self.backgroundColor = .black + self.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.leadingAnchor.constraint(equalTo: parent.leadingAnchor), + self.trailingAnchor.constraint(equalTo: parent.trailingAnchor), + self.topAnchor.constraint(equalTo: parent.topAnchor), + self.bottomAnchor.constraint(equalTo: parent.bottomAnchor), + ]) + } + + public override func layoutSubviews() { + super.layoutSubviews() + + for subview in subviews { + subview.frame = bounds + } + } +} + +class VLCPlayerWrapper: NSObject { + private var lastProgressCall = Date().timeIntervalSince1970 + public var player: VLCMediaPlayer = VLCMediaPlayer() + private var updatePlayerState: (() -> Void)? + private var updateVideoProgress: (() -> Void)? + private var playerView: VLCPlayerView = VLCPlayerView() + public weak var pipController: VLCPictureInPictureWindowControlling? + + override public init() { + super.init() + player.delegate = self + player.drawable = self + player.scaleFactor = 0 + } + + public func setup( + parent: UIView, + updatePlayerState: (() -> Void)?, + updateVideoProgress: (() -> Void)? + ) { + self.updatePlayerState = updatePlayerState + self.updateVideoProgress = updateVideoProgress + + player.delegate = self + parent.addSubview(playerView) + playerView.setupView(parent: parent) + } + + public func getPlayerView() -> UIView { + return playerView + } +} + +// MARK: - VLCPictureInPictureDrawable +extension VLCPlayerWrapper: VLCPictureInPictureDrawable { + public func mediaController() -> (any VLCPictureInPictureMediaControlling)! { + return self + } + + public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)! + { + return { [weak self] controller in + self?.pipController = controller + } + } +} + +// MARK: - VLCPictureInPictureMediaControlling +extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling { + func mediaTime() -> Int64 { + return player.time.value?.int64Value ?? 0 + } + + func mediaLength() -> Int64 { + return player.media?.length.value?.int64Value ?? 0 + } + + func play() { + player.play() + } + + func pause() { + player.pause() + } + + func seek(by offset: Int64, completion: @escaping () -> Void) { + player.jump(withOffset: Int32(offset), completion: completion) + } + + func isMediaSeekable() -> Bool { + return player.isSeekable + } + + func isMediaPlaying() -> Bool { + return player.isPlaying + } +} + +// MARK: - VLCDrawable +extension VLCPlayerWrapper: VLCDrawable { + public func addSubview(_ view: UIView) { + playerView.addSubview(view) + } + + public func bounds() -> CGRect { + return playerView.bounds + } +} + +// MARK: - VLCMediaPlayerDelegate +extension VLCPlayerWrapper: VLCMediaPlayerDelegate { + func mediaPlayerTimeChanged(_ aNotification: Notification) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + let timeNow = Date().timeIntervalSince1970 + if timeNow - self.lastProgressCall >= 1 { + self.lastProgressCall = timeNow + self.updateVideoProgress?() + } + } + } + + func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.updatePlayerState?() + + guard let pipController = self.pipController else { return } + pipController.invalidatePlaybackState() + } + } +} + + + +class VlcPlayer4View: ExpoView { + let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayer4View") + + private var vlc: VLCPlayerWrapper = VLCPlayerWrapper() + private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second + private var isPaused: Bool = false + private var customSubtitles: [(internalName: String, originalName: String)] = [] + private var startPosition: Int32 = 0 + private var externalTrack: [String: String]? + private var isStopping: Bool = false // Define isStopping here + private var externalSubtitles: [[String: String]]? + var hasSource = false + var initialSeekPerformed = false + // A flag variable determinging if we should perform the initial seek. Its either transcoding or offline playback. that makes + var shouldPerformInitialSeek: Bool = false + + + // MARK: - Initialization + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + setupVLC() + setupNotifications() + VLCManager.shared.listeners.append(self) + } + + // MARK: - Setup + private func setupVLC() { + vlc.setup( + parent: self, + updatePlayerState: updatePlayerState, + updateVideoProgress: updateVideoProgress + ) + } + + // Workaround: When playing an HLS video for the first time, seeking to a specific time immediately can cause a crash. + // To avoid this, we wait until the video has started playing before performing the initial seek. + func performInitialSeek() { + guard !initialSeekPerformed, + startPosition > 0, + shouldPerformInitialSeek, + vlc.player.isSeekable else { return } + + initialSeekPerformed = true + logger.debug("First time update, performing initial seek to \(self.startPosition) seconds") + vlc.player.time = VLCTime(int: startPosition * 1000) + } + + private func setupNotifications() { + NotificationCenter.default.addObserver( + self, selector: #selector(applicationWillResignActive), + name: UIApplication.willResignActiveNotification, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(applicationDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, object: nil) + } + + // MARK: - Public Methods + func startPictureInPicture() { + self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in + self.onPipStarted?(["pipStarted": isStarted]) + } + self.vlc.pipController?.startPictureInPicture() + } + + @objc func play() { + self.vlc.player.play() + self.isPaused = false + logger.debug("Play") + } + + @objc func pause() { + self.vlc.player.pause() + self.isPaused = true + } + + @objc func seekTo(_ time: Int32) { + let wasPlaying = vlc.player.isPlaying + if wasPlaying { + self.pause() + } + + if let duration = vlc.player.media?.length.intValue { + logger.debug("Seeking to time: \(time) Video Duration \(duration)") + + // If the specified time is greater than the duration, seek to the end + let seekTime = time > duration ? duration - 1000 : time + vlc.player.time = VLCTime(int: seekTime) + self.updatePlayerState() + + // Let mediaPlayerStateChanged handle play state change + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if wasPlaying { + self.play() + } + } + } else { + logger.error("Unable to retrieve video duration") + } + } + + @objc func setSource(_ source: [String: Any]) { + logger.debug("Setting source...") + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if self.hasSource { + return + } + + var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:] + self.externalTrack = source["externalTrack"] as? [String: String] + let initOptions: [String] = source["initOptions"] as? [String] ?? [] + self.startPosition = source["startPosition"] as? Int32 ?? 0 + self.externalSubtitles = source["externalSubtitles"] as? [[String: String]] + + for item in initOptions { + let option = item.components(separatedBy: "=") + mediaOptions.updateValue( + option[1], forKey: option[0].replacingOccurrences(of: "--", with: "")) + } + + guard let uri = source["uri"] as? String, !uri.isEmpty else { + logger.error("Invalid or empty URI") + self.onVideoError?(["error": "Invalid or empty URI"]) + return + } + + let autoplay = source["autoplay"] as? Bool ?? false + let isNetwork = source["isNetwork"] as? Bool ?? false + + // Set shouldPeformIntial based on isTranscoding and is not a network stream + self.shouldPerformInitialSeek = uri.contains("m3u8") || !isNetwork + self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()]) + + let media: VLCMedia! + if isNetwork { + logger.debug("Loading network file: \(uri)") + media = VLCMedia(url: URL(string: uri)!) + } else { + logger.debug("Loading local file: \(uri)") + if uri.starts(with: "file://"), let url = URL(string: uri) { + media = VLCMedia(url: url) + } else { + media = VLCMedia(path: uri) + } + } + + logger.debug("Media options: \(mediaOptions)") + media.addOptions(mediaOptions) + + self.vlc.player.media = media + self.setInitialExternalSubtitles() + self.hasSource = true + if autoplay { + logger.info("Playing...") + // The Video is not transcoding so it its safe to seek to the start position. + if !self.shouldPerformInitialSeek { + self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000)) + } + self.play() + } + } + } + + @objc func setAudioTrack(_ trackIndex: Int) { + print("Setting audio track: \(trackIndex)") + let track = self.vlc.player.audioTracks[trackIndex] + track.isSelectedExclusively = true + } + + @objc func getAudioTracks() -> [[String: Any]]? { + return vlc.player.audioTracks.enumerated().map { + return ["name": $1.trackName, "index": $0] + } + } + + @objc func setSubtitleTrack(_ trackIndex: Int) { + logger.debug("Attempting to set subtitle track to index: \(trackIndex)") + if trackIndex == -1 { + logger.debug("Disabling all subtitles") + for track in self.vlc.player.textTracks { + track.isSelected = false + } + return + } + let track = self.vlc.player.textTracks[trackIndex] + track.isSelectedExclusively = true; + logger.debug("Current subtitle track index after setting: \(track.trackName)") + } + + @objc func setSubtitleURL(_ subtitleURL: String, name: String) { + guard let url = URL(string: subtitleURL) else { + logger.error("Invalid subtitle URL") + return + } + let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false) + if result == 0 { + let internalName = "Track \(self.customSubtitles.count)" + self.customSubtitles.append((internalName: internalName, originalName: name)) + logger.debug("Subtitle added with result: \(result) \(internalName)") + } else { + logger.debug("Failed to add subtitle") + } + } + + @objc func getSubtitleTracks() -> [[String: Any]]? { + if self.vlc.player.textTracks.count == 0 { + return nil + } + + logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)") + + let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in + if let customSubtitle = customSubtitles.first(where: { + $0.internalName == track.trackName + }) { + return ["name": customSubtitle.originalName, "index": index] + } else { + return ["name": track.trackName, "index": index] + } + } + + logger.debug("Subtitle tracks: \(tracks)") + return tracks + } + + @objc func stop(completion: (() -> Void)? = nil) { + logger.debug("Stopping media...") + guard !isStopping else { + completion?() + return + } + isStopping = true + + // If we're not on the main thread, dispatch to main thread + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.performStop(completion: completion) + } + } else { + performStop(completion: completion) + } + } + + // MARK: - Private Methods + + @objc private func applicationWillResignActive() { + + } + + @objc private func applicationDidBecomeActive() { + + } + + private func setInitialExternalSubtitles() { + if let externalSubtitles = self.externalSubtitles { + for subtitle in externalSubtitles { + if let subtitleName = subtitle["name"], + let subtitleURL = subtitle["DeliveryUrl"] + { + print("Setting external subtitle: \(subtitleName) \(subtitleURL)") + self.setSubtitleURL(subtitleURL, name: subtitleName) + } + } + } + } + + private func performStop(completion: (() -> Void)? = nil) { + // Stop the media player + vlc.player.stop() + + // Remove observer + NotificationCenter.default.removeObserver(self) + + // Clear the video view + vlc.getPlayerView().removeFromSuperview() + + isStopping = false + completion?() + } + + private func updateVideoProgress() { + guard self.vlc.player.media != nil else { return } + + let currentTimeMs = self.vlc.player.time.intValue + let durationMs = self.vlc.player.media?.length.intValue ?? 0 + + logger.debug("Current time: \(currentTimeMs)") + self.onVideoProgress?([ + "currentTime": currentTimeMs, + "duration": durationMs, + ]) + } + + private func updatePlayerState() { + let player = self.vlc.player + if player.isPlaying { + performInitialSeek() + } + self.onVideoStateChange?([ + "target": self.reactTag ?? NSNull(), + "currentTime": player.time.intValue, + "duration": player.media?.length.intValue ?? 0, + "error": false, + "isPlaying": player.isPlaying, + "isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering, + "state": player.state.description, + ]) + } + + // MARK: - Expo Events + @objc var onPlaybackStateChanged: RCTDirectEventBlock? + @objc var onVideoLoadStart: RCTDirectEventBlock? + @objc var onVideoStateChange: RCTDirectEventBlock? + @objc var onVideoProgress: RCTDirectEventBlock? + @objc var onVideoLoadEnd: RCTDirectEventBlock? + @objc var onVideoError: RCTDirectEventBlock? + @objc var onPipStarted: RCTDirectEventBlock? + + // MARK: - Deinitialization + + deinit { + logger.debug("Deinitialization") + performStop() + VLCManager.shared.listeners.removeAll() + } +} + +// MARK: - SimpleAppLifecycleListener +extension VlcPlayer4View: SimpleAppLifecycleListener { + func applicationDidEnterBackground() { + logger.debug("Entering background") + } + + func applicationDidEnterForeground() { + logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)") + if !self.vlc.getPlayerView().isDescendant(of: self) { + logger.debug("Player view is missing. Adding back as subview") + self.addSubview(self.vlc.getPlayerView()) + } + + // Current solution to fixing black screen when re-entering application + if let videoTrack = self.vlc.player.videoTracks.first(where: { $0.isSelected == true }), + !self.vlc.isMediaPlaying() + { + videoTrack.isSelected = false + videoTrack.isSelectedExclusively = true + self.vlc.player.play() + self.vlc.player.pause() + } + } +} + +extension VLCMediaPlayerState { + var description: String { + switch self { + case .opening: return "Opening" + case .buffering: return "Buffering" + case .playing: return "Playing" + case .paused: return "Paused" + case .stopped: return "Stopped" + case .error: return "Error" + case .stopping: return "Stopping" + @unknown default: return "Unknown" + } + } +} diff --git a/modules/vlc-player-3/src/VlcPlayer3Module.ts b/modules/vlc-player-4/src/VlcPlayer4Module.ts similarity index 80% rename from modules/vlc-player-3/src/VlcPlayer3Module.ts rename to modules/vlc-player-4/src/VlcPlayer4Module.ts index c0501304..9e489bd2 100644 --- a/modules/vlc-player-3/src/VlcPlayer3Module.ts +++ b/modules/vlc-player-4/src/VlcPlayer4Module.ts @@ -2,4 +2,4 @@ import { requireNativeModule } from "expo-modules-core"; // It loads the native module object from the JSI or falls back to // the bridge module (from NativeModulesProxy) if the remote debugger is on. -export default requireNativeModule("VlcPlayer3"); +export default requireNativeModule("VlcPlayer4"); 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 7b4d8721..2a67cf26 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 @@ -62,6 +62,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context private var startPosition: Int? = 0 private var isMediaReady: Boolean = false private var externalTrack: Map? = null + private var externalSubtitles: List>? = null var hasSource: Boolean = false private val handler = Handler(Looper.getMainLooper()) @@ -220,6 +221,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context val autoplay = source["autoplay"] as? Boolean ?: false val isNetwork = source["isNetwork"] as? Boolean ?: false externalTrack = source["externalTrack"] as? Map + externalSubtitles = source["externalSubtitles"] as? List> startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0 val initOptions = source["initOptions"] as? MutableList ?: mutableListOf() @@ -240,20 +242,11 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context media = Media(libVLC, Uri.parse(uri)) mediaPlayer?.media = media - log.debug("Debug: Media options: $mediaOptions") // media.addOptions(mediaOptions) - // Apply subtitle options - // val subtitleTrackIndex = source["subtitleTrackIndex"] as? Int ?: -1 - // Log.d("VlcPlayerView", "Debug: Subtitle track index from source: $subtitleTrackIndex") - - // if (subtitleTrackIndex >= -1) { - // setSubtitleTrack(subtitleTrackIndex) - // Log.d("VlcPlayerView", "Debug: Set subtitle track to index: $subtitleTrackIndex") - // } else { - // Log.d("VlcPlayerView", "Debug: Subtitle track index is less than -1, not setting") - // } + // Set initial external subtitles immediately like iOS + setInitialExternalSubtitles() hasSource = true @@ -342,6 +335,19 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true) } + private fun setInitialExternalSubtitles() { + externalSubtitles?.let { subtitles -> + for (subtitle in subtitles) { + val subtitleName = subtitle["name"] + val subtitleURL = subtitle["DeliveryUrl"] + if (!subtitleName.isNullOrEmpty() && !subtitleURL.isNullOrEmpty()) { + log.debug("Setting external subtitle: $subtitleName $subtitleURL") + setSubtitleURL(subtitleURL, subtitleName) + } + } + } + } + override fun onDetachedFromWindow() { log.debug("onDetachedFromWindow") super.onDetachedFromWindow() diff --git a/modules/vlc-player/expo-module.config.json b/modules/vlc-player/expo-module.config.json index c7159245..2fbd3167 100644 --- a/modules/vlc-player/expo-module.config.json +++ b/modules/vlc-player/expo-module.config.json @@ -1,8 +1,7 @@ { "platforms": ["ios", "tvos", "android", "web"], "ios": { - "modules": ["VlcPlayerModule"], - "appDelegateSubscribers": ["AppLifecycleDelegate"] + "modules": ["VlcPlayerModule"] }, "android": { "modules": ["expo.modules.vlcplayer.VlcPlayerModule"] diff --git a/modules/vlc-player/ios/VlcPlayer.podspec b/modules/vlc-player/ios/VlcPlayer.podspec index 5c88f736..af95170d 100644 --- a/modules/vlc-player/ios/VlcPlayer.podspec +++ b/modules/vlc-player/ios/VlcPlayer.podspec @@ -1,22 +1,23 @@ Pod::Spec.new do |s| s.name = 'VlcPlayer' - s.version = '4.0.0a10' + s.version = '3.6.1b1' s.summary = 'A sample project summary' s.description = 'A sample project description' s.author = '' s.homepage = 'https://docs.expo.dev/modules/' - s.platforms = { :ios => '13.4', :tvos => '16' } + s.platforms = { :ios => '13.4', :tvos => '13.4' } s.source = { git: '' } s.static_framework = true s.dependency 'ExpoModulesCore' - s.ios.dependency 'VLCKit', s.version - s.tvos.dependency 'VLCKit', s.version + s.ios.dependency 'MobileVLCKit', s.version + s.tvos.dependency 'TVVLCKit', s.version # Swift/Objective-C compatibility 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/vlc-player/ios/VlcPlayerModule.swift b/modules/vlc-player/ios/VlcPlayerModule.swift index 38299392..14e8f01a 100644 --- a/modules/vlc-player/ios/VlcPlayerModule.swift +++ b/modules/vlc-player/ios/VlcPlayerModule.swift @@ -54,6 +54,10 @@ public class VlcPlayerModule: Module { return view.getAudioTracks() } + AsyncFunction("setSubtitleURL") { (view: VlcPlayerView, url: String, name: String) in + view.setSubtitleURL(url, name: name) + } + AsyncFunction("setSubtitleTrack") { (view: VlcPlayerView, trackIndex: Int) in view.setSubtitleTrack(trackIndex) } @@ -61,11 +65,6 @@ public class VlcPlayerModule: Module { AsyncFunction("getSubtitleTracks") { (view: VlcPlayerView) -> [[String: Any]]? in return view.getSubtitleTracks() } - - AsyncFunction("setSubtitleURL") { - (view: VlcPlayerView, url: String, name: String) in - view.setSubtitleURL(url, name: name) - } } } } diff --git a/modules/vlc-player/ios/VlcPlayerView.swift b/modules/vlc-player/ios/VlcPlayerView.swift index f02478d2..dbedfa2b 100644 --- a/modules/vlc-player/ios/VlcPlayerView.swift +++ b/modules/vlc-player/ios/VlcPlayerView.swift @@ -1,175 +1,56 @@ import ExpoModulesCore -import UIKit -import VLCKit -import os -public class VLCPlayerView: UIView { - func setupView(parent: UIView) { - self.backgroundColor = .black - self.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - self.leadingAnchor.constraint(equalTo: parent.leadingAnchor), - self.trailingAnchor.constraint(equalTo: parent.trailingAnchor), - self.topAnchor.constraint(equalTo: parent.topAnchor), - self.bottomAnchor.constraint(equalTo: parent.bottomAnchor), - ]) - } - - public override func layoutSubviews() { - super.layoutSubviews() - - for subview in subviews { - subview.frame = bounds - } - } -} - -class VLCPlayerWrapper: NSObject { - private var lastProgressCall = Date().timeIntervalSince1970 - public var player: VLCMediaPlayer = VLCMediaPlayer() - private var updatePlayerState: (() -> Void)? - private var updateVideoProgress: (() -> Void)? - private var playerView: VLCPlayerView = VLCPlayerView() - public weak var pipController: VLCPictureInPictureWindowControlling? - - override public init() { - super.init() - player.delegate = self - player.drawable = self - player.scaleFactor = 0 - } - - public func setup( - parent: UIView, - updatePlayerState: (() -> Void)?, - updateVideoProgress: (() -> Void)? - ) { - self.updatePlayerState = updatePlayerState - self.updateVideoProgress = updateVideoProgress - - player.delegate = self - parent.addSubview(playerView) - playerView.setupView(parent: parent) - } - - public func getPlayerView() -> UIView { - return playerView - } -} - -// MARK: - VLCPictureInPictureDrawable -extension VLCPlayerWrapper: VLCPictureInPictureDrawable { - public func mediaController() -> (any VLCPictureInPictureMediaControlling)! { - return self - } - - public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)! - { - return { [weak self] controller in - self?.pipController = controller - } - } -} - -// MARK: - VLCPictureInPictureMediaControlling -extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling { - func mediaTime() -> Int64 { - return player.time.value?.int64Value ?? 0 - } - - func mediaLength() -> Int64 { - return player.media?.length.value?.int64Value ?? 0 - } - - func play() { - player.play() - } - - func pause() { - player.pause() - } - - func seek(by offset: Int64, completion: @escaping () -> Void) { - player.jump(withOffset: Int32(offset), completion: completion) - } - - func isMediaSeekable() -> Bool { - return player.isSeekable - } - - func isMediaPlaying() -> Bool { - return player.isPlaying - } -} - -// MARK: - VLCDrawable -extension VLCPlayerWrapper: VLCDrawable { - public func addSubview(_ view: UIView) { - playerView.addSubview(view) - } - - public func bounds() -> CGRect { - return playerView.bounds - } -} - -// MARK: - VLCMediaPlayerDelegate -extension VLCPlayerWrapper: VLCMediaPlayerDelegate { - func mediaPlayerTimeChanged(_ aNotification: Notification) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - let timeNow = Date().timeIntervalSince1970 - if timeNow - self.lastProgressCall >= 1 { - self.lastProgressCall = timeNow - self.updateVideoProgress?() - } - } - } - - func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.updatePlayerState?() - - guard let pipController = self.pipController else { return } - pipController.invalidatePlaybackState() - } - } -} - -// MARK: - VLCMediaDelegate -extension VLCPlayerWrapper: VLCMediaDelegate { - // Implement VLCMediaDelegate methods if needed -} +#if os(tvOS) + import TVVLCKit +#else + import MobileVLCKit +#endif class VlcPlayerView: ExpoView { - let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView") - - private var vlc: VLCPlayerWrapper = VLCPlayerWrapper() + private var mediaPlayer: VLCMediaPlayer? + private var videoView: UIView? private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second private var isPaused: Bool = false + private var currentGeometryCString: [CChar]? + private var lastReportedState: VLCMediaPlayerState? + private var lastReportedIsPlaying: Bool? private var customSubtitles: [(internalName: String, originalName: String)] = [] private var startPosition: Int32 = 0 - private var externalTrack: [String: String]? - private var isStopping: Bool = false // Define isStopping here private var externalSubtitles: [[String: String]]? + private var externalTrack: [String: String]? + private var progressTimer: DispatchSourceTimer? + private var isStopping: Bool = false // Define isStopping here + private var lastProgressCall = Date().timeIntervalSince1970 var hasSource = false + var isTranscoding = false + private var initialSeekPerformed: Bool = false // MARK: - Initialization + required init(appContext: AppContext? = nil) { super.init(appContext: appContext) - setupVLC() + setupView() setupNotifications() - VLCManager.shared.listeners.append(self) } // MARK: - Setup - private func setupVLC() { - vlc.setup( - parent: self, - updatePlayerState: updatePlayerState, - updateVideoProgress: updateVideoProgress - ) + + private func setupView() { + DispatchQueue.main.async { + self.backgroundColor = .black + self.videoView = UIView() + self.videoView?.translatesAutoresizingMaskIntoConstraints = false + + if let videoView = self.videoView { + self.addSubview(videoView) + NSLayoutConstraint.activate([ + videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + videoView.topAnchor.constraint(equalTo: self.topAnchor), + videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + ]) + } + } } private func setupNotifications() { @@ -182,86 +63,83 @@ class VlcPlayerView: ExpoView { } // MARK: - Public Methods - func startPictureInPicture() { - self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in - self.onPipStarted?(["pipStarted": isStarted]) - } - self.vlc.pipController?.startPictureInPicture() - } + func startPictureInPicture() {} @objc func play() { - self.vlc.player.play() + self.mediaPlayer?.play() self.isPaused = false - logger.debug("Play") + print("Play") } @objc func pause() { - self.vlc.player.pause() + self.mediaPlayer?.pause() self.isPaused = true } @objc func seekTo(_ time: Int32) { - let wasPlaying = vlc.player.isPlaying + guard let player = self.mediaPlayer else { return } + + let wasPlaying = player.isPlaying if wasPlaying { self.pause() } - if let duration = vlc.player.media?.length.intValue { - logger.debug("Seeking to time: \(time) Video Duration \(duration)") + if let duration = player.media?.length.intValue { + print("Seeking to time: \(time) Video Duration \(duration)") // If the specified time is greater than the duration, seek to the end let seekTime = time > duration ? duration - 1000 : time - vlc.player.time = VLCTime(int: seekTime) - self.updatePlayerState() - - // Let mediaPlayerStateChanged handle play state change - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - if wasPlaying { - self.play() - } + player.time = VLCTime(int: seekTime) + if wasPlaying { + self.play() } + self.updatePlayerState() } else { - logger.error("Unable to retrieve video duration") + print("Error: Unable to retrieve video duration") } } @objc func setSource(_ source: [String: Any]) { - logger.debug("Setting source...") DispatchQueue.main.async { [weak self] in guard let self = self else { return } if self.hasSource { return } - var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:] + let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:] self.externalTrack = source["externalTrack"] as? [String: String] - let initOptions: [String] = source["initOptions"] as? [String] ?? [] + var initOptions = source["initOptions"] as? [Any] ?? [] self.startPosition = source["startPosition"] as? Int32 ?? 0 self.externalSubtitles = source["externalSubtitles"] as? [[String: String]] - for item in initOptions { - let option = item.components(separatedBy: "=") - mediaOptions.updateValue( - option[1], forKey: option[0].replacingOccurrences(of: "--", with: "")) - } - guard let uri = source["uri"] as? String, !uri.isEmpty else { - logger.error("Invalid or empty URI") + print("Error: Invalid or empty URI") self.onVideoError?(["error": "Invalid or empty URI"]) return } + + self.isTranscoding = uri.contains("m3u8") + + if !self.isTranscoding, self.startPosition > 0 { + initOptions.append("--start-time=\(self.startPosition)") + } let autoplay = source["autoplay"] as? Bool ?? false let isNetwork = source["isNetwork"] as? Bool ?? false self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()]) + self.mediaPlayer = VLCMediaPlayer(options: initOptions) + self.mediaPlayer?.delegate = self + self.mediaPlayer?.drawable = self.videoView + self.mediaPlayer?.scaleFactor = 0 + self.initialSeekPerformed = false - let media: VLCMedia! + let media: VLCMedia if isNetwork { - logger.debug("Loading network file: \(uri)") + print("Loading network file: \(uri)") media = VLCMedia(url: URL(string: uri)!) } else { - logger.debug("Loading local file: \(uri)") + print("Loading local file: \(uri)") if uri.starts(with: "file://"), let url = URL(string: uri) { media = VLCMedia(url: url) } else { @@ -269,84 +147,103 @@ class VlcPlayerView: ExpoView { } } - logger.debug("Media options: \(mediaOptions)") + print("Debug: Media options: \(mediaOptions)") media.addOptions(mediaOptions) - self.vlc.player.media = media + self.mediaPlayer?.media = media self.setInitialExternalSubtitles() self.hasSource = true if autoplay { - logger.info("Playing...") + print("Playing...") self.play() - self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000)) } } } @objc func setAudioTrack(_ trackIndex: Int) { - print("Setting audio track: \(trackIndex)") - let track = self.vlc.player.audioTracks[trackIndex] - track.isSelectedExclusively = true + self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex) } @objc func getAudioTracks() -> [[String: Any]]? { - return vlc.player.audioTracks.enumerated().map { - return ["name": $1.trackName, "index": $0] + guard let trackNames = mediaPlayer?.audioTrackNames, + let trackIndexes = mediaPlayer?.audioTrackIndexes + else { + return nil + } + + return zip(trackNames, trackIndexes).map { name, index in + return ["name": name, "index": index] } } @objc func setSubtitleTrack(_ trackIndex: Int) { - logger.debug("Attempting to set subtitle track to index: \(trackIndex)") - if trackIndex == -1 { - logger.debug("Disabling all subtitles") - for track in self.vlc.player.textTracks { - track.isSelected = false - } - return - } - let track = self.vlc.player.textTracks[trackIndex] - track.isSelectedExclusively = true; - logger.debug("Current subtitle track index after setting: \(track.trackName)") + print("Debug: Attempting to set subtitle track to index: \(trackIndex)") + self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex) + print( + "Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)" + ) } @objc func setSubtitleURL(_ subtitleURL: String, name: String) { guard let url = URL(string: subtitleURL) else { - logger.error("Invalid subtitle URL") + print("Error: Invalid subtitle URL") return } - let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false) - if result == 0 { + + let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: false) + if let result = result { let internalName = "Track \(self.customSubtitles.count)" + print("Subtitle added with result: \(result) \(internalName)") self.customSubtitles.append((internalName: internalName, originalName: name)) - logger.debug("Subtitle added with result: \(result) \(internalName)") } else { - logger.debug("Failed to add subtitle") + print("Failed to add subtitle") + } + } + + private func setInitialExternalSubtitles() { + if let externalSubtitles = self.externalSubtitles { + for subtitle in externalSubtitles { + if let subtitleName = subtitle["name"], + let subtitleURL = subtitle["DeliveryUrl"] + { + print("Setting external subtitle: \(subtitleName) \(subtitleURL)") + self.setSubtitleURL(subtitleURL, name: subtitleName) + } + } } } @objc func getSubtitleTracks() -> [[String: Any]]? { - if self.vlc.player.textTracks.count == 0 { + guard let mediaPlayer = self.mediaPlayer else { return nil } - logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)") + let count = mediaPlayer.numberOfSubtitlesTracks + print("Debug: Number of subtitle tracks: \(count)") - let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in - if let customSubtitle = customSubtitles.first(where: { - $0.internalName == track.trackName - }) { - return ["name": customSubtitle.originalName, "index": index] - } else { - return ["name": track.trackName, "index": index] + guard count > 0 else { + return nil + } + + var tracks: [[String: Any]] = [] + + if let names = mediaPlayer.videoSubTitlesNames as? [String], + let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] + { + for (index, name) in zip(indexes, names) { + if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) { + tracks.append(["name": customSubtitle.originalName, "index": index.intValue]) + } else { + tracks.append(["name": name, "index": index.intValue]) + } } } - logger.debug("Subtitle tracks: \(tracks)") - return tracks + print("Debug: Subtitle tracks: \(tracks)") + return tracks } @objc func stop(completion: (() -> Void)? = nil) { - logger.debug("Stopping media...") guard !isStopping else { completion?() return @@ -373,60 +270,47 @@ class VlcPlayerView: ExpoView { } - private func setInitialExternalSubtitles() { - if let externalSubtitles = self.externalSubtitles { - for subtitle in externalSubtitles { - if let subtitleName = subtitle["name"], - let subtitleURL = subtitle["DeliveryUrl"] - { - print("Setting external subtitle: \(subtitleName) \(subtitleURL)") - self.setSubtitleURL(subtitleURL, name: subtitleName) - } - } - } - } - private func performStop(completion: (() -> Void)? = nil) { // Stop the media player - vlc.player.stop() + mediaPlayer?.stop() // Remove observer NotificationCenter.default.removeObserver(self) // Clear the video view - vlc.getPlayerView().removeFromSuperview() + videoView?.removeFromSuperview() + videoView = nil + + // Release the media player + mediaPlayer?.delegate = nil + mediaPlayer = nil isStopping = false completion?() } private func updateVideoProgress() { - guard self.vlc.player.media != nil else { return } + guard let player = self.mediaPlayer else { return } - let currentTimeMs = self.vlc.player.time.intValue - let durationMs = self.vlc.player.media?.length.intValue ?? 0 + let currentTimeMs = player.time.intValue + let durationMs = player.media?.length.intValue ?? 0 + - logger.debug("Current time: \(currentTimeMs)") - self.onVideoProgress?([ - "currentTime": currentTimeMs, - "duration": durationMs, - ]) - } - - private func updatePlayerState() { - let player = self.vlc.player - self.onVideoStateChange?([ - "target": self.reactTag ?? NSNull(), - "currentTime": player.time.intValue, - "duration": player.media?.length.intValue ?? 0, - "error": false, - "isPlaying": player.isPlaying, - "isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering, - "state": player.state.description, - ]) + print("Debug: Current time: \(currentTimeMs)") + if currentTimeMs >= 0 && currentTimeMs < durationMs { + if self.isTranscoding, !self.initialSeekPerformed, self.startPosition > 0 { + player.time = VLCTime(int: self.startPosition * 1000) + self.initialSeekPerformed = true + } + self.onVideoProgress?([ + "currentTime": currentTimeMs, + "duration": durationMs, + ]) + } } // MARK: - Expo Events + @objc var onPlaybackStateChanged: RCTDirectEventBlock? @objc var onVideoLoadStart: RCTDirectEventBlock? @objc var onVideoStateChange: RCTDirectEventBlock? @@ -438,37 +322,71 @@ class VlcPlayerView: ExpoView { // MARK: - Deinitialization deinit { - logger.debug("Deinitialization") performStop() - VLCManager.shared.listeners.removeAll() } } -// MARK: - SimpleAppLifecycleListener -extension VlcPlayerView: SimpleAppLifecycleListener { - func applicationDidEnterBackground() { - logger.debug("Entering background") +extension VlcPlayerView: VLCMediaPlayerDelegate { + func mediaPlayerTimeChanged(_ aNotification: Notification) { + // self?.updateVideoProgress() + let timeNow = Date().timeIntervalSince1970 + if timeNow - lastProgressCall >= 1 { + lastProgressCall = timeNow + updateVideoProgress() + } } - func applicationDidEnterForeground() { - logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)") - if !self.vlc.getPlayerView().isDescendant(of: self) { - logger.debug("Player view is missing. Adding back as subview") - self.addSubview(self.vlc.getPlayerView()) + func mediaPlayerStateChanged(_ aNotification: Notification) { + self.updatePlayerState() + } + + private func updatePlayerState() { + guard let player = self.mediaPlayer else { return } + let currentState = player.state + + var stateInfo: [String: Any] = [ + "target": self.reactTag ?? NSNull(), + "currentTime": player.time.intValue, + "duration": player.media?.length.intValue ?? 0, + "error": false, + ] + + if player.isPlaying { + stateInfo["isPlaying"] = true + stateInfo["isBuffering"] = false + stateInfo["state"] = "Playing" + } else { + stateInfo["isPlaying"] = false + stateInfo["state"] = "Paused" } - // Current solution to fixing black screen when re-entering application - if let videoTrack = self.vlc.player.videoTracks.first(where: { $0.isSelected == true }), - !self.vlc.isMediaPlaying() + if player.state == VLCMediaPlayerState.buffering { + stateInfo["isBuffering"] = true + stateInfo["state"] = "Buffering" + } else if player.state == VLCMediaPlayerState.error { + print("player.state ~ error") + stateInfo["state"] = "Error" + self.onVideoLoadEnd?(stateInfo) + } else if player.state == VLCMediaPlayerState.opening { + print("player.state ~ opening") + stateInfo["state"] = "Opening" + } + + if self.lastReportedState != currentState + || self.lastReportedIsPlaying != player.isPlaying { - videoTrack.isSelected = false - videoTrack.isSelectedExclusively = true - self.vlc.player.play() - self.vlc.player.pause() + self.lastReportedState = currentState + self.lastReportedIsPlaying = player.isPlaying + self.onVideoStateChange?(stateInfo) } + } } +extension VlcPlayerView: VLCMediaDelegate { + // Implement VLCMediaDelegate methods if needed +} + extension VLCMediaPlayerState { var description: String { switch self { @@ -477,9 +395,10 @@ extension VLCMediaPlayerState { case .playing: return "Playing" case .paused: return "Paused" case .stopped: return "Stopped" + case .ended: return "Ended" case .error: return "Error" - case .stopping: return "Stopping" + case .esAdded: return "ESAdded" @unknown default: return "Unknown" } } -} +} \ No newline at end of file diff --git a/plugins/withRNBackgroundDownloader.js b/plugins/withRNBackgroundDownloader.js index 0ddfd050..47664561 100644 --- a/plugins/withRNBackgroundDownloader.js +++ b/plugins/withRNBackgroundDownloader.js @@ -49,13 +49,13 @@ function withRNBackgroundDownloader(config) { // Expo 53's xcode‑js doesn't expose pbxTargets(). // Setting the property once at the project level is sufficient. - ["Debug", "Release"].forEach((cfg) => + ["Debug", "Release"].forEach((cfg) => { project.updateBuildProperty( "SWIFT_OBJC_BRIDGING_HEADER", "Streamyfin/Streamyfin-Bridging-Header.h", cfg, - ), - ); + ); + }); return mod; }); diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index b344dc1d..88885f75 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -2,16 +2,12 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query"; -import axios from "axios"; import * as Application from "expo-application"; -import type { FileInfo } from "expo-file-system"; import * as FileSystem from "expo-file-system"; -import Notifications from "expo-notifications"; -import { useRouter } from "expo-router"; +import { router } from "expo-router"; import { atom, useAtom } from "jotai"; -import type React from "react"; -import { +import { throttle } from "lodash"; +import React, { createContext, useCallback, useContext, @@ -19,413 +15,485 @@ import { useMemo, } from "react"; import { useTranslation } from "react-i18next"; -import { AppState, type AppStateStatus, Platform } from "react-native"; +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 { DownloadMethod, useSettings } from "@/utils/atoms/settings"; +import { generateTrickplayUrl, getTrickplayInfo } from "@/hooks/useTrickplay"; +import { useSettings } from "@/utils/atoms/settings"; import { getOrSetDeviceId } from "@/utils/device"; import useDownloadHelper from "@/utils/download"; import { getItemImage } from "@/utils/getItemImage"; -import { useLog, writeToLog } from "@/utils/log"; +import { writeToLog } from "@/utils/log"; import { storage } from "@/utils/mmkv"; -import { - cancelAllJobs, - cancelJobById, - deleteDownloadItemInfoFromDiskTmp, - getAllJobsByDeviceId, - getDownloadItemInfoFromDiskTmp, - type JobStatus, -} from "@/utils/optimize-server"; +import { fetchAndParseSegments } from "@/utils/segments"; import { Bitrate } from "../components/BitrateSelector"; +import { + DownloadedItem, + DownloadsDatabase, + JobStatus, + TrickPlayData, +} from "./Downloads/types"; import { apiAtom } from "./JellyfinProvider"; const BackGroundDownloader = !Platform.isTV ? require("@kesha-antonov/react-native-background-downloader") : null; -export type DownloadedItem = { - item: Partial; - mediaSource: MediaSourceInfo; +const calculateEstimatedSize = (p: JobStatus): number => { + let size = p.mediaSource.Size; + const maxBitrate = p.maxBitrate.value; + if ( + maxBitrate && + size && + p.mediaSource.Bitrate && + maxBitrate < p.mediaSource.Bitrate + ) { + size = (size / p.mediaSource.Bitrate) * maxBitrate; + } + // This function is for estimated size, so just return the adjusted size + return size ?? 0; +}; + +// Helper to calculate download speed +const calculateSpeed = ( + process: JobStatus, + newBytesDownloaded: number, +): number | undefined => { + const { bytesDownloaded: oldBytes = 0, lastProgressUpdateTime } = process; + const deltaBytes = newBytesDownloaded - oldBytes; + + if (lastProgressUpdateTime && deltaBytes > 0) { + const deltaTimeInSeconds = + (Date.now() - new Date(lastProgressUpdateTime).getTime()) / 1000; + if (deltaTimeInSeconds > 0) { + return deltaBytes / deltaTimeInSeconds; + } + } + return undefined; }; export const processesAtom = atom([]); - -function onAppStateChange(status: AppStateStatus) { - focusManager.setFocused(status === "active"); -} +const DOWNLOADS_DATABASE_KEY = "downloads.v2.json"; const DownloadContext = createContext | null>(null); function useDownloadProvider() { - const queryClient = useQueryClient(); const { t } = useTranslation(); - const [settings] = useSettings(); - const router = useRouter(); const [api] = useAtom(apiAtom); - const { logs } = useLog(); - const { saveSeriesPrimaryImage } = useDownloadHelper(); const { saveImage } = useImageStorage(); - - let [processes, setProcesses] = useAtom(processesAtom); - + const [processes, setProcesses] = useAtom(processesAtom); + const [settings] = useSettings(); const successHapticFeedback = useHaptic("success"); + /// 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) => t.metadata && !processes.some((p) => p.id === t.id)) + .map((t) => { + return t.metadata as JobStatus; + }); + + const currentProcesses = [...processes, ...missingProcesses]; + const updatedProcesses = currentProcesses.map((p) => { + // fallback. Doesn't really work for transcodes as they may be a lot smaller. + // We make an wild guess by comparing bitrates + const task = tasks.find((s) => s.id === p.id); + if (task && p.status === "downloading") { + const estimatedSize = calculateEstimatedSize(p); + let progress = p.progress; + if (estimatedSize > 0) { + progress = (100 / estimatedSize) * task.bytesDownloaded; + } + if (progress >= 100) { + progress = 99; + } + const speed = calculateSpeed(p, task.bytesDownloaded); + return { + ...p, + progress, + speed, + bytesDownloaded: task.bytesDownloaded, + lastProgressUpdateTime: new Date(), + estimatedTotalSizeBytes: estimatedSize, + }; + } + 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]; + } + + // If not in movies, 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; + } + } + } + } + + return undefined; + }; + + const updateProcess = useCallback( + ( + processId: string, + updater: + | Partial + | ((current: JobStatus) => Partial), + ) => { + setProcesses((prev) => + prev.map((p) => { + if (p.id !== processId) return p; + const newStatus = + typeof updater === "function" ? updater(p) : updater; + return { + ...p, + ...newStatus, + }; + }), + ); + }, + [setProcesses], + ); + const authHeader = useMemo(() => { return api?.accessToken; }, [api]); - const usingOptimizedServer = useMemo( - () => settings?.downloadMethod === DownloadMethod.Optimized, - [settings], - ); - - const getDownloadUrl = (process: JobStatus) => { - return usingOptimizedServer - ? `${settings.optimizedVersionsServerUrl}download/${process.id}` - : process.inputUrl; - }; - - const { data: downloadedFiles, refetch } = useQuery({ - queryKey: ["downloadedItems"], - queryFn: getAllDownloadedItems, - staleTime: 0, - refetchOnMount: true, - refetchOnReconnect: true, - refetchOnWindowFocus: true, - }); - - useEffect(() => { - const subscription = AppState.addEventListener("change", onAppStateChange); - - return () => subscription.remove(); - }, []); - - useQuery({ - queryKey: ["jobs"], - queryFn: async () => { - const deviceId = await getOrSetDeviceId(); - const url = settings?.optimizedVersionsServerUrl; - - if ( - settings?.downloadMethod !== DownloadMethod.Optimized || - !url || - !deviceId || - !authHeader - ) - return []; - - const jobs = await getAllJobsByDeviceId({ - deviceId, - authHeader, - url, - }); - - const downloadingProcesses = processes - .filter((p) => p.status === "downloading") - .filter((p) => jobs.some((j) => j.id === p.id)); - - const updatedProcesses = jobs.filter( - (j) => !downloadingProcesses.some((p) => p.id === j.id), - ); - - setProcesses([...updatedProcesses, ...downloadingProcesses]); - - for (const job of jobs) { - const process = processes.find((p) => p.id === job.id); - if ( - process && - process.status === "optimizing" && - job.status === "completed" - ) { - if (settings.autoDownload) { - startDownload(job); - } else { - toast.info( - t("home.downloads.toasts.item_is_ready_to_be_downloaded", { - item: job.item.Name, - }), - { - action: { - label: t("home.downloads.toasts.go_to_downloads"), - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }, - ); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: `${job.item.Name} is ready to be downloaded`, - data: { - url: "/downloads", - }, - }, - trigger: null, - }); - } - } - } - - return jobs; - }, - staleTime: 0, - refetchInterval: 2000, - enabled: settings?.downloadMethod === DownloadMethod.Optimized, - }); - - /// Cant use the background downloader callback. As its not triggered if size is unknown. - const updateProgress = async () => { - if (settings?.downloadMethod === DownloadMethod.Optimized) { - return; - } - - // const response = await getSessionApi(api).getSessions({ - // activeWithinSeconds: 300, - // }); - - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - - // check if processes are missing - const missingProcesses = tasks - .filter((t) => !processes.some((p) => p.id === t.id)) - .map((t) => { - return t.metadata; - }); - - processes = [...processes, ...missingProcesses]; - - const updatedProcesses = processes.map((p) => { - // const result = response.data.find((s) => s.Id == p.sessionId); - // if (result) { - // return { - // ...p, - // progress: result.TranscodingInfo?.CompletionPercentage, - // }; - // } - - // fallback. Doesn't really work for transcodes as they may be a lot smaller. - // We make an wild guess by comparing bitrates - const task = tasks.find((s) => s.id === p.id); - if (task) { - let progress = p.progress; - let size = p.mediaSource.Size; - const maxBitrate = p.maxBitrate.value; - if (maxBitrate && maxBitrate < p.mediaSource.Bitrate) { - size = (size / p.mediaSource.Bitrate) * maxBitrate; - } - progress = (100 / size) * task.bytesDownloaded; - if (progress >= 100) { - progress = 99; - } - - return { - ...p, - progress, - }; - } - return p; - }); - - setProcesses(updatedProcesses); - }; - - useInterval(updateProgress, 2000); - - useEffect(() => { - const checkIfShouldStartDownload = async () => { - if (processes.length === 0) return; - await BackGroundDownloader?.checkForExistingDownloads(); - }; - - checkIfShouldStartDownload(); - }, [settings, processes]); - - const removeProcess = useCallback( - async (id: string) => { - const deviceId = await getOrSetDeviceId(); - if (!deviceId || !authHeader) return; - - if (usingOptimizedServer) { - try { - await cancelJobById({ - authHeader, - id, - url: settings?.optimizedVersionsServerUrl, - }); - } catch (error) { - console.error(error); - } - } - - setProcesses((prev: any[]) => { - return prev.filter( - (process: { itemId: string | undefined }) => process.id !== id, - ); - }); - }, - [settings?.optimizedVersionsServerUrl, authHeader], - ); - 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: {} }; + }; + + 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), + ), + ), + ]; + 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"); - setProcesses((prev) => - prev.map((p) => - p.id === process.id - ? { - ...p, - speed: undefined, - status: "downloading", - progress: 0, - } - : p, - ), - ); + updateProcess(process.id, { + speed: undefined, + status: "downloading", + progress: 0, + }); BackGroundDownloader?.setConfig({ - isLogsEnabled: true, + isLogsEnabled: false, progressInterval: 500, headers: { Authorization: authHeader, }, }); - - toast.info( - t("home.downloads.toasts.download_stated_for_item", { - item: process.item.Name, - }), - { - action: { - label: t("home.downloads.toasts.go_to_downloads"), - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }, - ); - - const baseDirectory = FileSystem.documentDirectory; - + const filename = generateFilename(process.item); + const videoFilePath = `${FileSystem.documentDirectory}${filename}.mp4`; BackGroundDownloader?.download({ id: process.id, - url: getDownloadUrl(process), - destination: `${baseDirectory}/${process.item.Id}.mp4`, + url: process.inputUrl, + destination: videoFilePath, metadata: process, }) .begin(() => { - setProcesses((prev) => - prev.map((p) => - p.id === process.id - ? { - ...p, - speed: undefined, - status: "downloading", - progress: 0, - } - : p, - ), - ); + updateProcess(process.id, { + status: "downloading", + progress: 0, + bytesDownloaded: 0, + lastProgressUpdateTime: new Date(), + }); }) - .progress((data) => { - if (!usingOptimizedServer) { - return; + .progress( + throttle((data) => { + updateProcess(process.id, (currentProcess) => { + const percent = (data.bytesDownloaded / data.bytesTotal) * 100; + return { + speed: calculateSpeed(currentProcess, data.bytesDownloaded), + status: "downloading", + progress: percent, + bytesDownloaded: data.bytesDownloaded, + lastProgressUpdateTime: 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 percent = (data.bytesDownloaded / data.bytesTotal) * 100; - setProcesses((prev) => - prev.map((p) => - p.id === process.id - ? { - ...p, - speed: undefined, - status: "downloading", - progress: percent, - } - : p, - ), - ); - }) - .done(async (doneHandler) => { - await saveDownloadedItemInfo( - process.item, - doneHandler.bytesDownloaded, + 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; + } + await saveDownloadsDatabase(db); + toast.success( t("home.downloads.toasts.download_completed_for_item", { item: process.item.Name, }), - { - duration: 3000, - action: { - label: t("home.downloads.toasts.go_to_downloads"), - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }, ); - setTimeout(() => { - BackGroundDownloader.completeHandler(process.id); - removeProcess(process.id); - }, 1000); - }) - .error(async (error) => { removeProcess(process.id); - BackGroundDownloader.completeHandler(process.id); - let errorMsg = ""; - if (error.errorCode === 1000) { - errorMsg = "No space left"; - } - if (error.errorCode === 404) { - errorMsg = "File not found on server"; - } + }) + .error((error) => { + console.error("Download error:", error); toast.error( t("home.downloads.toasts.download_failed_for_item", { item: process.item.Name, - error: errorMsg, }), ); - writeToLog("ERROR", `Download failed for ${process.item.Name}`, { - error, - processDetails: { - id: process.id, - itemName: process.item.Name, - itemId: process.item.Id, - }, - }); - console.error("Error details:", { - errorCode: error.errorCode, - }); + removeProcess(process.id); }); }, - [queryClient, settings?.optimizedVersionsServerUrl, authHeader], + [authHeader], ); + 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) { + startDownload(queuedDownload); + } + } + }, [processes, settings?.remuxConcurrentLimit, startDownload]); + + const removeProcess = useCallback( + async (id: string) => { + const tasks = await BackGroundDownloader.checkForExistingDownloads(); + const task = tasks?.find((t) => t.id === id); + task?.stop(); + BackGroundDownloader.completeHandler(id); + setProcesses((prev) => prev.filter((process) => process.id !== id)); + manageDownloadQueue(); + }, + [setProcesses, manageDownloadQueue], + ); + + useEffect(() => { + manageDownloadQueue(); + }, [processes, manageDownloadQueue]); + + /** + * 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("Failed to clean cache directory.")); + } + }; + const startBackgroundDownload = useCallback( async ( url: string, item: BaseItemDto, mediaSource: MediaSourceInfo, - maxBitrate?: Bitrate, + maxBitrate: Bitrate, ) => { if (!api || !item.Id || !authHeader) throw new Error("startBackgroundDownload ~ Missing required params"); - try { - const fileExtension = mediaSource.TranscodingContainer; - const deviceId = await getOrSetDeviceId(); - + const deviceId = getOrSetDeviceId(); await saveSeriesPrimaryImage(item); const itemImage = getItemImage({ item, @@ -435,46 +503,21 @@ function useDownloadProvider() { width: 500, }); await saveImage(item.Id, itemImage?.uri); - if (usingOptimizedServer) { - const response = await axios.post( - `${settings?.optimizedVersionsServerUrl}optimize-version`, - { - url, - fileExtension, - deviceId, - itemId: item.Id, - item, - }, - { - headers: { - "Content-Type": "application/json", - Authorization: authHeader, - }, - }, - ); - - if (response.status !== 201) { - throw new Error("Failed to start optimization job"); - } - } else { - const job: JobStatus = { - id: item.Id!, - deviceId: deviceId, - inputUrl: url, - item: item, - itemId: item.Id!, - mediaSource, - progress: 0, - maxBitrate, - status: "downloading", - timestamp: new Date(), - }; - setProcesses([...processes, job]); - startDownload(job); - } - + 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) => [...prev, job]); toast.success( - t("home.downloads.toasts.queued_item_for_optimization", { + t("home.downloads.toasts.download_stated_for_item", { item: item.Name, }), { @@ -489,360 +532,184 @@ function useDownloadProvider() { ); } catch (error) { writeToLog("ERROR", "Error in startBackgroundDownload", error); - console.error("Error in startBackgroundDownload:", error); - if (axios.isAxiosError(error)) { - console.error("Axios error details:", { - message: error.message, - response: error.response?.data, - status: error.response?.status, - headers: error.response?.headers, - }); - toast.error( - t("home.downloads.toasts.failed_to_start_download_for_item", { - item: item.Name, - message: error.message, - }), - ); - if (error.response) { - toast.error( - t("home.downloads.toasts.server_responded_with_status", { - statusCode: error.response.status, - }), - ); - } else if (error.request) { - t("home.downloads.toasts.no_response_received_from_server"); - } else { - toast.error("Error setting up the request"); - } - } else { - console.error("Non-Axios error:", error); - toast.error( - t( - "home.downloads.toasts.failed_to_start_download_for_item_unexpected_error", - { item: item.Name }, - ), - ); - } } }, - [settings?.optimizedVersionsServerUrl, authHeader], + [authHeader, startDownload], ); - const deleteAllFiles = async (): Promise => { - Promise.all([ - deleteLocalFiles(), - removeDownloadedItemsFromStorage(), - cancelAllServerJobs(), - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }), - ]) - .then(() => - toast.success( - t( - "home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully", - ), - ), - ) - .catch((reason) => { - console.error("Failed to delete all files, folders, and jobs:", reason); - toast.error( - t( - "home.downloads.toasts.an_error_occured_while_deleting_files_and_jobs", - ), - ); - }); - }; + const deleteFile = async (id: string, type: "Movie" | "Episode") => { + const db = getDownloadsDatabase(); + let downloadedItem: DownloadedItem | undefined; - const forEveryDocumentDirFile = async ( - includeMMKV: boolean, - ignoreList: string[], - callback: (file: FileInfo) => void, - ) => { - const baseDirectory = FileSystem.documentDirectory; - if (!baseDirectory) { - throw new Error("Base directory not found"); - } - - const dirContents = await FileSystem.readDirectoryAsync(baseDirectory); - for (const item of dirContents) { - // Exclude mmkv directory. - // Deleting this deletes all user information as well. Logout should handle this. - if ( - (item === "mmkv" && !includeMMKV) || - ignoreList.some((i) => item.includes(i)) - ) { - continue; + if (type === "Movie") { + downloadedItem = db.movies[id]; + if (downloadedItem) { + delete db.movies[id]; } - await FileSystem.getInfoAsync(`${baseDirectory}${item}`) - .then((itemInfo) => { - if (itemInfo.exists && !itemInfo.isDirectory) { - callback(itemInfo); + } else if (type === "Episode") { + 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; + } } - }) - .catch((e) => console.error(e)); - } - }; - - const deleteLocalFiles = async (): Promise => { - await forEveryDocumentDirFile(false, [], (file) => { - console.warn("Deleting file", file.uri); - FileSystem.deleteAsync(file.uri, { idempotent: true }); - }); - }; - - const removeDownloadedItemsFromStorage = async () => { - // delete any saved images first - Promise.all([deleteFileByType("Movie"), deleteFileByType("Episode")]) - .then(() => storage.delete("downloadedItems")) - .catch((reason) => { - console.error("Failed to remove downloadedItems from storage:", reason); - throw reason; - }); - }; - - const cancelAllServerJobs = async (): Promise => { - if (!authHeader) { - throw new Error("No auth header available"); - } - if (!settings?.optimizedVersionsServerUrl) { - console.error("No server URL configured"); - return; - } - - const deviceId = await getOrSetDeviceId(); - if (!deviceId) { - throw new Error("Failed to get device ID"); - } - - try { - await cancelAllJobs({ - authHeader, - url: settings.optimizedVersionsServerUrl, - deviceId, - }); - } catch (error) { - console.error("Failed to cancel all server jobs:", error); - throw error; - } - }; - - const deleteFile = async (id: string): Promise => { - if (!id) { - console.error("Invalid file ID"); - return; - } - - try { - const directory = FileSystem.documentDirectory; - - if (!directory) { - console.error("Document directory not found"); - return; + if (downloadedItem) break; + } + if (downloadedItem) break; } - const dirContents = await FileSystem.readDirectoryAsync(directory); + } - for (const item of dirContents) { - const itemNameWithoutExtension = item.split(".")[0]; - if (itemNameWithoutExtension === id) { - const filePath = `${directory}${item}`; - await FileSystem.deleteAsync(filePath, { idempotent: true }); - break; + 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, + }); } } - - const downloadedItems = storage.getString("downloadedItems"); - if (downloadedItems) { - let items = JSON.parse(downloadedItems) as DownloadedItem[]; - items = items.filter((item) => item.item.Id !== id); - storage.set("downloadedItems", JSON.stringify(items)); - } - - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); - } catch (error) { - console.error( - `Failed to delete file and storage entry for ID ${id}:`, - error, - ); } + + if (downloadedItem?.trickPlayData?.path) { + await FileSystem.deleteAsync(downloadedItem.trickPlayData.path, { + idempotent: true, + }); + } + + await saveDownloadsDatabase(db); + successHapticFeedback(); }; const deleteItems = async (items: BaseItemDto[]) => { - Promise.all( - items.map((i) => { - if (i.Id) return deleteFile(i.Id); - return; - }), - ).then(() => successHapticFeedback()); - }; - - const cleanCacheDirectory = async () => { - const cacheDir = await FileSystem.getInfoAsync( - APP_CACHE_DOWNLOAD_DIRECTORY, - ); - if (cacheDir.exists) { - const cachedFiles = await FileSystem.readDirectoryAsync( - APP_CACHE_DOWNLOAD_DIRECTORY, - ); - let position = 0; - const batchSize = 3; - - // batching promise.all to avoid OOM - while (position < cachedFiles.length) { - const itemsForBatch = cachedFiles.slice(position, position + batchSize); - await Promise.all( - itemsForBatch.map(async (file) => { - const info = await FileSystem.getInfoAsync( - `${APP_CACHE_DOWNLOAD_DIRECTORY}${file}`, - ); - if (info.exists) { - await FileSystem.deleteAsync(info.uri, { idempotent: true }); - return Promise.resolve(file); - } - return Promise.reject(); - }), - ); - - position += batchSize; + for (const item of items) { + if (item.Id && (item.Type === "Movie" || item.Type === "Episode")) { + 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"]) => { - await Promise.all( - downloadedFiles - ?.filter((file) => file.item.Type === type) - ?.flatMap((file) => { - const promises = []; - if (type === "Episode" && file.item.SeriesId) - promises.push(deleteFile(file.item.SeriesId)); - promises.push(deleteFile(file.item.Id!)); - return promises; - }) || [], + const itemsToDelete = downloadedItems?.filter( + (file) => file.item.Type === type, ); + if (itemsToDelete) await deleteItems(itemsToDelete.map((i) => i.item)); }; - const appSizeUsage = useMemo(async () => { - const sizes: number[] = - downloadedFiles?.map((d) => { - return getDownloadedItemSize(d.item.Id!); - }) || []; + /** Returns the size of a downloaded item. */ + const getDownloadedItemSize = (itemId: string): number => { + const downloadedItem = getDownloadedItemById(itemId); + if (!downloadedItem) return 0; - await forEveryDocumentDirFile( - true, - getAllDownloadedItems().map((d) => d.item.Id!), - (file) => { - if (file.exists) { - sizes.push(file.size); + 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 { + 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; + } + } } - }, - ).catch((e) => { - console.error(e); - }); - - return sizes.reduce((sum, size) => sum + size, 0); - }, [logs, downloadedFiles, forEveryDocumentDirFile]); - - function getDownloadedItem(itemId: string): DownloadedItem | null { - try { - const downloadedItems = storage.getString("downloadedItems"); - if (downloadedItems) { - const items: DownloadedItem[] = JSON.parse(downloadedItems); - const item = items.find((i) => i.item.Id === itemId); - return item || null; } - return null; - } catch (error) { - console.error(`Failed to retrieve item with ID ${itemId}:`, error); - return null; } - } + saveDownloadsDatabase(db); + }; - function getAllDownloadedItems(): DownloadedItem[] { - try { - const downloadedItems = storage.getString("downloadedItems"); - if (downloadedItems) { - return JSON.parse(downloadedItems) as DownloadedItem[]; - } - return []; - } catch (error) { - console.error("Failed to retrieve downloaded items:", error); - return []; - } - } + /** + * 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(), + ]); - function saveDownloadedItemInfo(item: BaseItemDto, size = 0) { - try { - const downloadedItems = storage.getString("downloadedItems"); - const items: DownloadedItem[] = downloadedItems - ? JSON.parse(downloadedItems) - : []; - - const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id); - - const data = getDownloadItemInfoFromDiskTmp(item.Id!); - - if (!data?.mediaSource) - throw new Error( - "Media source not found in tmp storage. Did you forget to save it before starting download?", - ); - - const newItem = { item, mediaSource: data.mediaSource }; - - if (existingItemIndex !== -1) { - items[existingItemIndex] = newItem; - } else { - items.push(newItem); - } - - deleteDownloadItemInfoFromDiskTmp(item.Id!); - - storage.set("downloadedItems", JSON.stringify(items)); - storage.set(`downloadedItemSize-${item.Id}`, size.toString()); - - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); - refetch(); - } catch (error) { - console.error( - "Failed to save downloaded item information with media source:", - error, + 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; + } } - } - - function getDownloadedItemSize(itemId: string): number { - const size = storage.getString(`downloadedItemSize-${itemId}`); - return size ? Number.parseInt(size) : 0; - } + return { total, remaining, app: appSize }; + }; return { processes, startBackgroundDownload, - downloadedFiles, + getDownloadedItems, + getDownloadsDatabase, deleteAllFiles, deleteFile, deleteItems, - saveDownloadedItemInfo, removeProcess, - setProcesses, startDownload, - getDownloadedItem, deleteFileByType, - appSizeUsage, getDownloadedItemSize, + getDownloadedItemById, APP_CACHE_DOWNLOAD_DIRECTORY, cleanCacheDirectory, + updateDownloadedItem, + appSizeUsage, }; } -export function DownloadProvider({ children }: { children: React.ReactNode }) { - const downloadProviderValue = useDownloadProvider(); - - return ( - - {children} - - ); -} - export function useDownload() { const context = useContext(DownloadContext); @@ -850,31 +717,36 @@ export function useDownload() { // Since tv doesn't do downloads, just return no-op functions for everything return { processes: [], - startBackgroundDownload: async ( - _url: string, - _item: BaseItemDto, - _mediaSource: MediaSourceInfo, - _maxBitrate?: Bitrate, - ) => {}, - downloadedFiles: [], - deleteAllFiles: async (): Promise => {}, - deleteFile: async (_id: string): Promise => {}, - deleteItems: async (_items: BaseItemDto[]) => {}, - saveDownloadedItemInfo: (_item: BaseItemDto, _size?: number) => {}, - removeProcess: (_id: string) => {}, - setProcesses: () => {}, - startDownload: async (_process: JobStatus): Promise => {}, - getDownloadedItem: (_itemId: string) => {}, - deleteFileByType: async (_type: BaseItemDto["Type"]) => {}, - appSizeUsage: async () => 0, - getDownloadedItemSize: (_itemId: string) => {}, + startBackgroundDownload: async () => {}, + getDownloadedItems: () => [], + getDownloadsDatabase: () => ({}), + deleteAllFiles: async () => {}, + deleteFile: async () => {}, + deleteItems: async () => {}, + removeProcess: () => {}, + startDownload: async () => {}, + deleteFileByType: async () => {}, + getDownloadedItemSize: () => 0, + getDownloadedItemById: () => undefined, APP_CACHE_DOWNLOAD_DIRECTORY: "", - cleanCacheDirectory: async (): Promise => {}, + cleanCacheDirectory: async () => {}, + updateDownloadedItem: () => {}, + appSizeUsage: async () => ({ total: 0, remaining: 0, app: 0 }), }; } if (context === null) { throw new Error("useDownload must be used within a DownloadProvider"); } + return context; } + +export function DownloadProvider({ children }: { children: React.ReactNode }) { + const downloadUtils = useDownloadProvider(); + return ( + + {children} + + ); +} diff --git a/providers/Downloads/types.ts b/providers/Downloads/types.ts new file mode 100644 index 00000000..ee74b25d --- /dev/null +++ b/providers/Downloads/types.ts @@ -0,0 +1,132 @@ +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { Bitrate } from "@/components/BitrateSelector"; + +/** + * Represents the data for downloaded trickplay files. + */ +export interface TrickPlayData { + /** The local directory path where trickplay image sheets are stored. */ + path: string; + /** The total size of all trickplay images in bytes. */ + size: number; +} + +/** + * Represents the user data for a downloaded item. + */ +interface UserData { + subtitleStreamIndex: number; + /** The last known audio stream index. */ + audioStreamIndex: number; +} + +/** Represents a segment of time in a media item, used for intro/credit skipping. */ +export interface MediaTimeSegment { + startTime: number; + endTime: number; + text: string; +} + +export interface Segment { + startTime: number; + endTime: number; + text: string; +} + +/** Represents a single downloaded media item with all necessary metadata for offline playback. */ +export interface DownloadedItem { + /** The Jellyfin item DTO. */ + item: BaseItemDto; + /** The media source information. */ + mediaSource: MediaSourceInfo; + /** The local file path of the downloaded video. */ + videoFilePath: string; + /** The size of the video file in bytes. */ + videoFileSize: number; + /** The local file path of the downloaded trickplay images. */ + trickPlayData?: TrickPlayData; + /** The intro segments for the item. */ + introSegments?: MediaTimeSegment[]; + /** The credit segments for the item. */ + creditSegments?: MediaTimeSegment[]; + /** The user data for the item. */ + userData: UserData; +} +/** + * Represents a downloaded Season, containing a map of its episodes. + */ +export interface DownloadedSeason { + /** A map of episode numbers to their downloaded item data. */ + episodes: Record; +} + +/** + * Represents a downloaded series, containing seasons and their episodes. + */ +export interface DownloadedSeries { + /** The Jellyfin item DTO for the series. */ + seriesInfo: BaseItemDto; + /** A map of season numbers to their downloaded season data. */ + seasons: Record< + number, + { + /** A map of episode numbers to their downloaded episode data. */ + episodes: Record; + } + >; +} + +/** + * The main structure for all downloaded content stored locally. + * This object is what will be saved to your local storage. + */ +export interface DownloadsDatabase { + /** A map of movie IDs to their downloaded movie data. */ + movies: Record; + /** A map of series IDs to their downloaded series data. */ + series: Record; +} + +/** + * Represents the status of a download job. + */ +export type JobStatus = { + /** Unique identifier for the download job (also the {@link itemId}) */ + id: string; + /** The input URL for the media to be downloaded (passed in when first downloading) */ + inputUrl: string; + /** The Jellyfin {@link BaseItemDto} associated with this job */ + item: BaseItemDto; + /** The ID of the item being downloaded */ + itemId: string; + /** The device ID where the download is occurring */ + deviceId: string; + /** Download progress as a percentage (0-100) */ + progress: number; + /** 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 + | "queued"; // The job is queued to start + /** Timestamp of when the job was created or last updated */ + timestamp: Date; + /** The {@link MediaSourceInfo} for the download */ + mediaSource: MediaSourceInfo; + /** The bit rate we are downloading the media file atq */ + maxBitrate: Bitrate; + /** The number of bytes downloaded so far (optional) */ + bytesDownloaded?: number; + /** The last time the download progress was updated (optional) */ + lastProgressUpdateTime?: Date; + /** Current download speed in bytes per second (optional) */ + speed?: number; + /** 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; +}; diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index aada18f9..745024d7 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -380,8 +380,6 @@ function useProtectedRoute(user: UserDto | null, loaded = false) { useEffect(() => { if (loaded === false) return; - console.log("Loaded", user); - const inAuthGroup = segments[0] === "(auth)"; if (!user?.Id && inAuthGroup) { diff --git a/providers/JobQueueProvider.tsx b/providers/JobQueueProvider.tsx deleted file mode 100644 index 87df1800..00000000 --- a/providers/JobQueueProvider.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type React from "react"; -import { createContext } from "react"; -import { useJobProcessor } from "@/utils/atoms/queue"; - -const JobQueueContext = createContext(null); - -export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - useJobProcessor(); - - return ( - {children} - ); -}; diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx index df876d3e..38806a54 100644 --- a/providers/PlaySettingsProvider.tsx +++ b/providers/PlaySettingsProvider.tsx @@ -8,7 +8,7 @@ import { createContext, useCallback, useContext, useState } from "react"; import type { Bitrate } from "@/components/BitrateSelector"; import { settingsAtom } from "@/utils/atoms/settings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import generateDeviceProfile from "@/utils/profiles/native"; +import { generateDeviceProfile } from "@/utils/profiles/native"; import { apiAtom, userAtom } from "./JellyfinProvider"; export type PlaybackType = { @@ -77,7 +77,7 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ } try { - const native = await generateDeviceProfile(); + const native = generateDeviceProfile(); const data = await getStreamUrl({ api, deviceProfile: native, diff --git a/translations/en.json b/translations/en.json index 7b8faad5..ffbb8f41 100644 --- a/translations/en.json +++ b/translations/en.json @@ -408,6 +408,7 @@ "download_episode": "Download Episode", "download_movie": "Download Movie", "download_x_item": "Download {{item_count}} items", + "download_unwatched_only": "Unwatched Only", "download_button": "Download", "using_optimized_server": "Using optimized server", "using_default_method": "Using default method" diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts index 2b510728..3664da98 100644 --- a/utils/atoms/queue.ts +++ b/utils/atoms/queue.ts @@ -2,8 +2,8 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; 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"; -import type { JobStatus } from "@/utils/optimize-server"; export interface Job { id: string; @@ -68,5 +68,5 @@ export const useJobProcessor = () => { console.info("Processing queue", queue); queueActions.processJob(queue, setQueue, setRunning); } - }, [processes, queue, running, setQueue, setRunning]); + }, [processes, queue, running, setQueue, setRunning, settings]); }; diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 0762bc5b..39b1ed27 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -81,7 +81,6 @@ export type DefaultLanguageOption = { export enum DownloadMethod { Remux = "remux", - Optimized = "optimized", } export type Home = { @@ -155,7 +154,6 @@ export type Settings = { defaultVideoOrientation: ScreenOrientation.OrientationLock; forwardSkipTime: number; rewindSkipTime: number; - optimizedVersionsServerUrl?: string | null; downloadMethod: DownloadMethod; autoDownload: boolean; showCustomMenuLinks: boolean; @@ -212,7 +210,6 @@ const defaultValues: Settings = { defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT, forwardSkipTime: 30, rewindSkipTime: 10, - optimizedVersionsServerUrl: null, downloadMethod: DownloadMethod.Remux, autoDownload: false, showCustomMenuLinks: false, diff --git a/utils/eventBus.ts b/utils/eventBus.ts index e4bd0fc1..97062c70 100644 --- a/utils/eventBus.ts +++ b/utils/eventBus.ts @@ -19,7 +19,9 @@ class EventBus { } emit(event: string, data?: T): void { - this.listeners[event]?.forEach((callback) => callback(data)); + this.listeners[event]?.forEach((callback) => { + callback(data); + }); } } diff --git a/utils/jellyfin/getDefaultPlaySettings.ts b/utils/jellyfin/getDefaultPlaySettings.ts index 88e97059..db3d97c9 100644 --- a/utils/jellyfin/getDefaultPlaySettings.ts +++ b/utils/jellyfin/getDefaultPlaySettings.ts @@ -51,18 +51,9 @@ export function getDefaultPlaySettings( const mediaSource = item.MediaSources?.[0]; - // 2. Get default or preferred audio - const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex; - const _preferedAudioIndex = mediaSource?.MediaStreams?.find( - (x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage, - )?.Index; - const _firstAudioIndex = mediaSource?.MediaStreams?.find( - (x) => x.Type === "Audio", - )?.Index; - // We prefer the previous track over the default track. const trackOptions: TrackOptions = { - DefaultAudioStreamIndex: defaultAudioIndex ?? -1, + DefaultAudioStreamIndex: mediaSource?.DefaultAudioStreamIndex ?? -1, DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1, }; diff --git a/utils/jellyfin/media/getDownloadUrl.ts b/utils/jellyfin/media/getDownloadUrl.ts new file mode 100644 index 00000000..33734d4b --- /dev/null +++ b/utils/jellyfin/media/getDownloadUrl.ts @@ -0,0 +1,68 @@ +import type { Api } from "@jellyfin/sdk"; +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { Bitrate } from "@/components/BitrateSelector"; +import { generateDeviceProfile } from "@/utils/profiles/native"; +import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl"; + +export const getDownloadUrl = async ({ + api, + item, + userId, + mediaSource, + maxBitrate, + audioStreamIndex, + subtitleStreamIndex, + deviceId, +}: { + api: Api; + item: BaseItemDto; + userId: string; + mediaSource: MediaSourceInfo; + maxBitrate: Bitrate; + audioStreamIndex: number; + subtitleStreamIndex: number; + deviceId: string; +}): Promise<{ + url: string | null; + mediaSource: MediaSourceInfo | null; +} | null> => { + const streamDetails = await getStreamUrl({ + api, + item, + userId, + startTimeTicks: 0, + mediaSourceId: mediaSource.Id, + maxStreamingBitrate: maxBitrate.value, + audioStreamIndex, + subtitleStreamIndex, + deviceId, + deviceProfile: generateDeviceProfile(), + }); + + if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) { + console.log("Downloading item directly"); + return { + url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`, + mediaSource: streamDetails?.mediaSource ?? null, + }; + } + + const downloadStreamDetails = await getDownloadStreamUrl({ + api, + item, + userId, + mediaSourceId: mediaSource.Id, + deviceId, + maxStreamingBitrate: maxBitrate.value, + audioStreamIndex, + subtitleStreamIndex, + }); + + return { + url: downloadStreamDetails?.url ?? null, + mediaSource: downloadStreamDetails?.mediaSource ?? null, + }; +}; diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 86314fc0..d1497ded 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -4,7 +4,7 @@ import type { MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; -import generateDeviceProfile from "@/utils/profiles/native"; +import download from "@/utils/profiles/download"; export const getStreamUrl = async ({ api, @@ -13,11 +13,10 @@ export const getStreamUrl = async ({ startTimeTicks = 0, maxStreamingBitrate, playSessionId, - deviceProfile = generateDeviceProfile(), + deviceProfile, audioStreamIndex = 0, subtitleStreamIndex = undefined, mediaSourceId, - download = false, deviceId, }: { api: Api | null | undefined; @@ -26,12 +25,11 @@ export const getStreamUrl = async ({ startTimeTicks: number; maxStreamingBitrate?: number; playSessionId?: string | null; - deviceProfile?: any; + deviceProfile: any; audioStreamIndex?: number; subtitleStreamIndex?: number; height?: number; mediaSourceId?: string | null; - download?: bool; deviceId?: string | null; }): Promise<{ url: string | null; @@ -71,12 +69,16 @@ export const getStreamUrl = async ({ } sessionId = res.data.PlaySessionId || null; - mediaSource = res.data.MediaSources[0]; - let transcodeUrl = mediaSource.TranscodingUrl; + mediaSource = res.data.MediaSources?.[0]; + let transcodeUrl = mediaSource?.TranscodingUrl; if (transcodeUrl) { - if (download) { - transcodeUrl = transcodeUrl.replace("master.m3u8", "stream"); + // We need to change the subtitle method to hls for the transcoded url. + if (subtitleStreamIndex === -1) { + transcodeUrl = transcodeUrl.replace( + "SubtitleMethod=Encode", + "SubtitleMethod=Hls", + ); } console.log("Video is being transcoded:", transcodeUrl); return { @@ -86,21 +88,6 @@ export const getStreamUrl = async ({ }; } - let downloadParams = {}; - - if (download) { - // We need to disable static so we can have a remux with subtitle. - downloadParams = { - subtitleMethod: "Embed", - enableSubtitlesInManifest: true, - static: "false", - allowVideoStreamCopy: true, - allowAudioStreamCopy: true, - playSessionId: sessionId || "", - container: "ts", - }; - } - const streamParams = new URLSearchParams({ static: "true", container: "mp4", @@ -111,8 +98,7 @@ export const getStreamUrl = async ({ api_key: api.accessToken, startTimeTicks: startTimeTicks.toString(), maxStreamingBitrate: maxStreamingBitrate?.toString() || "", - userId: userId || "", - ...downloadParams, + userId: userId, }); const directPlayUrl = `${ @@ -123,7 +109,113 @@ export const getStreamUrl = async ({ return { url: directPlayUrl, - sessionId: sessionId || playSessionId, + sessionId: sessionId || playSessionId || null, + mediaSource, + }; +}; + +export const getDownloadStreamUrl = async ({ + api, + item, + userId, + maxStreamingBitrate, + audioStreamIndex = 0, + subtitleStreamIndex = undefined, + mediaSourceId, + deviceId, +}: { + api: Api | null | undefined; + item: BaseItemDto | null | undefined; + userId: string | null | undefined; + maxStreamingBitrate?: number; + audioStreamIndex?: number; + subtitleStreamIndex?: number; + mediaSourceId?: string | null; + deviceId?: string | null; +}): Promise<{ + url: string | null; + sessionId: string | null; + mediaSource: MediaSourceInfo | undefined; +} | null> => { + if (!api || !userId || !item?.Id) { + console.warn("Missing required parameters for getStreamUrl"); + return null; + } + + let mediaSource: MediaSourceInfo | undefined; + let sessionId: string | null | undefined; + + const res = await getMediaInfoApi(api).getPlaybackInfo( + { + itemId: item.Id!, + }, + { + method: "POST", + data: { + userId, + deviceProfile: download, + subtitleStreamIndex, + startTimeTicks: 0, + isPlayback: true, + autoOpenLiveStream: true, + maxStreamingBitrate, + audioStreamIndex, + mediaSourceId, + }, + }, + ); + + if (res.status !== 200) { + console.error("Error getting playback info:", res.status, res.statusText); + } + + sessionId = res.data.PlaySessionId || null; + mediaSource = res.data.MediaSources?.[0]; + let transcodeUrl = mediaSource?.TranscodingUrl; + + if (transcodeUrl) { + transcodeUrl = transcodeUrl.replace("master.m3u8", "stream"); + console.log("Video is being transcoded:", transcodeUrl); + return { + url: `${api.basePath}${transcodeUrl}`, + sessionId, + mediaSource, + }; + } + + const downloadParams = { + // We need to disable static so we can have a remux with subtitle. + subtitleMethod: "Embed", + enableSubtitlesInManifest: true, + allowVideoStreamCopy: true, + allowAudioStreamCopy: true, + playSessionId: sessionId || "", + }; + + const streamParams = new URLSearchParams({ + static: "false", + container: "ts", + mediaSourceId: mediaSource?.Id || "", + subtitleStreamIndex: subtitleStreamIndex?.toString() || "", + audioStreamIndex: audioStreamIndex?.toString() || "", + deviceId: deviceId || api.deviceInfo.id, + api_key: api.accessToken, + startTimeTicks: "0", + maxStreamingBitrate: maxStreamingBitrate?.toString() || "", + userId: userId, + }); + + Object.entries(downloadParams).forEach(([key, value]) => { + streamParams.append(key, value.toString()); + }); + + const directPlayUrl = `${ + api.basePath + }/Videos/${item.Id}/stream?${streamParams.toString()}`; + + return { + url: directPlayUrl, + sessionId: sessionId || null, mediaSource, }; }; diff --git a/utils/jellyfin/playstate/markAsNotPlayed.ts b/utils/jellyfin/playstate/markAsNotPlayed.ts deleted file mode 100644 index 1478c965..00000000 --- a/utils/jellyfin/playstate/markAsNotPlayed.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Api } from "@jellyfin/sdk"; -import type { AxiosError } from "axios"; - -interface MarkAsNotPlayedParams { - api: Api | null | undefined; - itemId: string | null | undefined; - userId: string | null | undefined; -} - -/** - * Marks a media item as not played for a specific user. - * - * @param params - The parameters for marking an item as not played - * @returns A promise that resolves to true if the operation was successful, false otherwise - */ -export const markAsNotPlayed = async ({ - api, - itemId, - userId, -}: MarkAsNotPlayedParams): Promise => { - if (!api || !itemId || !userId) { - console.error("Invalid parameters for markAsNotPlayed"); - return; - } - - try { - await api.axiosInstance.delete( - `${api.basePath}/UserPlayedItems/${itemId}`, - { - params: { userId }, - headers: { - Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, - }, - }, - ); - } catch (error) { - const axiosError = error as AxiosError; - console.error( - "Failed to mark item as not played:", - axiosError.message, - axiosError.response?.status, - ); - return; - } -}; diff --git a/utils/jellyfin/playstate/markAsPlayed.ts b/utils/jellyfin/playstate/markAsPlayed.ts deleted file mode 100644 index d73bb0cc..00000000 --- a/utils/jellyfin/playstate/markAsPlayed.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Api } from "@jellyfin/sdk"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; - -interface MarkAsPlayedParams { - api: Api | null | undefined; - item: BaseItemDto | null | undefined; - userId: string | null | undefined; -} - -/** - * Marks a media item as played and updates its progress to completion. - * - * @param params - The parameters for marking an item as played∏ - * @returns A promise that resolves to true if the operation was successful, false otherwise - */ -export const markAsPlayed = async ({ - api, - item, - userId, -}: MarkAsPlayedParams): Promise => { - if (!api || !item?.Id || !userId || !item.RunTimeTicks) { - console.error("Invalid parameters for markAsPlayed"); - return false; - } - - try { - const response = await getPlaystateApi(api).markPlayedItem({ - itemId: item.Id, - datePlayed: new Date().toISOString(), - }); - - return response.status === 200; - } catch (_error) { - return false; - } -}; diff --git a/utils/jellyfin/playstate/reportPlaybackProgress.ts b/utils/jellyfin/playstate/reportPlaybackProgress.ts deleted file mode 100644 index 00363c17..00000000 --- a/utils/jellyfin/playstate/reportPlaybackProgress.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Api } from "@jellyfin/sdk"; -import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; -import type { Settings } from "@/utils/atoms/settings"; - -interface ReportPlaybackProgressParams { - api?: Api | null; - sessionId?: string | null; - itemId?: string | null; - positionTicks?: number | null; - IsPaused?: boolean; - deviceProfile?: Settings["deviceProfile"]; -} - -/** - * Reports playback progress to the Jellyfin server. - * - * @param params - The parameters for reporting playback progress - * @throws {Error} If any required parameter is missing - */ -export const reportPlaybackProgress = async ({ - api, - sessionId, - itemId, - positionTicks, - IsPaused = false, -}: ReportPlaybackProgressParams): Promise => { - if (!api || !sessionId || !itemId || !positionTicks) { - return; - } - - console.info("reportPlaybackProgress ~ IsPaused", IsPaused); - - try { - await getPlaystateApi(api).onPlaybackProgress({ - itemId, - audioStreamIndex: 0, - subtitleStreamIndex: 0, - mediaSourceId: itemId, - positionTicks: Math.round(positionTicks), - isPaused: IsPaused, - isMuted: false, - playMethod: "Transcode", - }); - // await api.axiosInstance.post( - // `${api.basePath}/Sessions/Playing/Progress`, - // { - // ItemId: itemId, - // PlaySessionId: sessionId, - // IsPaused, - // PositionTicks: Math.round(positionTicks), - // CanSeek: true, - // MediaSourceId: itemId, - // EventName: "timeupdate", - // }, - // { headers: getAuthHeaders(api) } - // ); - } catch (error) { - console.error(error); - } -}; diff --git a/utils/jellyfin/session/capabilities.ts b/utils/jellyfin/session/capabilities.ts index f46f1dbf..910506ff 100644 --- a/utils/jellyfin/session/capabilities.ts +++ b/utils/jellyfin/session/capabilities.ts @@ -1,7 +1,7 @@ import type { Api } from "@jellyfin/sdk"; import type { AxiosResponse } from "axios"; import type { Settings } from "@/utils/atoms/settings"; -import generateDeviceProfile from "@/utils/profiles/native"; +import { generateDeviceProfile } from "@/utils/profiles/native"; import { getAuthHeaders } from "../jellyfin"; interface PostCapabilitiesParams { diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts deleted file mode 100644 index dee8e7b9..00000000 --- a/utils/optimize-server.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client"; -import axios from "axios"; -import { storage } from "@/utils/mmkv"; -import { writeToLog } from "./log"; - -interface IJobInput { - deviceId?: string | null; - authHeader?: string | null; - url?: string | null; -} - -export interface JobStatus { - id: string; - status: - | "queued" - | "optimizing" - | "completed" - | "failed" - | "cancelled" - | "downloading"; - progress: number; - outputPath: string; - inputUrl: string; - deviceId: string; - itemId: string; - item: BaseItemDto; - speed?: number; - timestamp: Date; - base64Image?: string; -} - -/** - * Fetches all jobs for a specific device. - * - * @param {IGetAllDeviceJobs} params - The parameters for the API request. - * @param {string} params.deviceId - The ID of the device to fetch jobs for. - * @param {string} params.authHeader - The authorization header for the API request. - * @param {string} params.url - The base URL for the API endpoint. - * - * @returns {Promise} A promise that resolves to an array of job statuses. - * - * @throws {Error} Throws an error if the API request fails or returns a non-200 status code. - */ -export async function getAllJobsByDeviceId({ - deviceId, - authHeader, - url, -}: IJobInput): Promise { - const statusResponse = await axios.get(`${url}all-jobs`, { - headers: { - Authorization: authHeader, - }, - params: { - deviceId, - }, - }); - if (statusResponse.status !== 200) { - console.error( - statusResponse.status, - statusResponse.data, - statusResponse.statusText, - ); - throw new Error("Failed to fetch job status"); - } - - return statusResponse.data; -} - -interface ICancelJob { - authHeader: string; - url: string; - id: string; -} - -export async function cancelJobById({ - authHeader, - url, - id, -}: ICancelJob): Promise { - const statusResponse = await axios.delete(`${url}cancel-job/${id}`, { - headers: { - Authorization: authHeader, - }, - }); - if (statusResponse.status !== 200) { - throw new Error("Failed to cancel process"); - } - - return true; -} - -export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) { - if (!deviceId) return false; - if (!authHeader) return false; - if (!url) return false; - - try { - await getAllJobsByDeviceId({ - deviceId, - authHeader, - url, - }).then((jobs) => { - for (const job of jobs) { - cancelJobById({ - authHeader, - url, - id: job.id, - }); - } - }); - } catch (error) { - writeToLog("ERROR", "Failed to cancel all jobs", error); - console.error(error); - return false; - } - - return true; -} - -/** - * Fetches statistics for a specific device. - * - * @param {IJobInput} params - The parameters for the API request. - * @param {string} params.deviceId - The ID of the device to fetch statistics for. - * @param {string} params.authHeader - The authorization header for the API request. - * @param {string} params.url - The base URL for the API endpoint. - * - * @returns {Promise} A promise that resolves to the statistics data or null if the request fails. - * - * @throws {Error} Throws an error if any required parameter is missing. - */ -export async function getStatistics({ - authHeader, - url, - deviceId, -}: IJobInput): Promise { - if (!deviceId || !authHeader || !url) { - return null; - } - - try { - const statusResponse = await axios.get(`${url}statistics`, { - headers: { - Authorization: authHeader, - }, - params: { - deviceId, - }, - }); - - return statusResponse.data; - } catch (error) { - console.error("Failed to fetch statistics:", error); - return null; - } -} - -/** - * Saves the download item info to disk - this data is used temporarily to fetch additional download information - * in combination with the optimize server. This is used to not have to send all item info to the optimize server. - * - * @param {BaseItemDto} item - The item to save. - * @param {MediaSourceInfo} mediaSource - The media source of the item. - * @param {string} url - The URL of the item. - * @return {boolean} A promise that resolves when the item info is saved. - */ -export function saveDownloadItemInfoToDiskTmp( - item: BaseItemDto, - mediaSource: MediaSourceInfo, - url: string, -): boolean { - try { - const downloadInfo = JSON.stringify({ - item, - mediaSource, - url, - }); - - storage.set(`tmp_download_info_${item.Id}`, downloadInfo); - - return true; - } catch (error) { - console.error("Failed to save download item info to disk:", error); - throw error; - } -} - -/** - * Retrieves the download item info from disk. - * - * @param {string} itemId - The ID of the item to retrieve. - * @return {{ - * item: BaseItemDto; - * mediaSource: MediaSourceInfo; - * url: string; - * } | null} The retrieved download item info or null if not found. - */ -export function getDownloadItemInfoFromDiskTmp(itemId: string): { - item: BaseItemDto; - mediaSource: MediaSourceInfo; - url: string; -} | null { - try { - const rawInfo = storage.getString(`tmp_download_info_${itemId}`); - - if (rawInfo) { - return JSON.parse(rawInfo); - } - return null; - } catch (error) { - console.error("Failed to retrieve download item info from disk:", error); - return null; - } -} - -/** - * Deletes the download item info from disk. - * - * @param {string} itemId - The ID of the item to delete. - * @return {boolean} True if the item info was successfully deleted, false otherwise. - */ -export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean { - try { - storage.delete(`tmp_download_info_${itemId}`); - return true; - } catch (error) { - console.error("Failed to delete download item info from disk:", error); - return false; - } -} diff --git a/utils/profiles/download.js b/utils/profiles/download.js index 4f0d4d4d..9e9d8fdc 100644 --- a/utils/profiles/download.js +++ b/utils/profiles/download.js @@ -59,80 +59,55 @@ export default { ], SubtitleProfiles: [ // Official foramts - { Format: "vtt", Method: "Embed" }, { Format: "vtt", Method: "Encode" }, - { Format: "webvtt", Method: "Embed" }, { Format: "webvtt", Method: "Encode" }, - { Format: "srt", Method: "Embed" }, { Format: "srt", Method: "Encode" }, - { Format: "subrip", Method: "Embed" }, { Format: "subrip", Method: "Encode" }, - { Format: "ttml", Method: "Embed" }, { Format: "ttml", Method: "Encode" }, - { Format: "dvbsub", Method: "Embed" }, { Format: "dvdsub", Method: "Encode" }, - { Format: "ass", Method: "Embed" }, { Format: "ass", Method: "Encode" }, - { Format: "idx", Method: "Embed" }, { Format: "idx", Method: "Encode" }, - { Format: "pgs", Method: "Embed" }, { Format: "pgs", Method: "Encode" }, - { Format: "pgssub", Method: "Embed" }, { Format: "pgssub", Method: "Encode" }, - { Format: "ssa", Method: "Embed" }, { Format: "ssa", Method: "Encode" }, // Other formats - { Format: "microdvd", Method: "Embed" }, { Format: "microdvd", Method: "Encode" }, - { Format: "mov_text", Method: "Embed" }, { Format: "mov_text", Method: "Encode" }, - { Format: "mpl2", Method: "Embed" }, { Format: "mpl2", Method: "Encode" }, - { Format: "pjs", Method: "Embed" }, { Format: "pjs", Method: "Encode" }, - { Format: "realtext", Method: "Embed" }, { Format: "realtext", Method: "Encode" }, - { Format: "scc", Method: "Embed" }, { Format: "scc", Method: "Encode" }, - { Format: "smi", Method: "Embed" }, { Format: "smi", Method: "Encode" }, - { Format: "stl", Method: "Embed" }, { Format: "stl", Method: "Encode" }, - { Format: "sub", Method: "Embed" }, { Format: "sub", Method: "Encode" }, - { Format: "subviewer", Method: "Embed" }, { Format: "subviewer", Method: "Encode" }, - { Format: "teletext", Method: "Embed" }, { Format: "teletext", Method: "Encode" }, - { Format: "text", Method: "Embed" }, { Format: "text", Method: "Encode" }, - { Format: "vplayer", Method: "Embed" }, { Format: "vplayer", Method: "Encode" }, - { Format: "xsub", Method: "Embed" }, { Format: "xsub", Method: "Encode" }, ], }; diff --git a/utils/profiles/native.js b/utils/profiles/native.js index 74d46fa4..afccb275 100644 --- a/utils/profiles/native.js +++ b/utils/profiles/native.js @@ -4,13 +4,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import MediaTypes from "../../constants/MediaTypes"; +import { getSubtitleProfiles } from "./subtitles"; -export const generateDeviceProfile = async () => { +export const generateDeviceProfile = ({ transcode = false } = {}) => { /** * Device profile for Native video player */ const profile = { - Name: "1. Vlc Player", + Name: `1. Vlc Player${transcode ? " (Transcoding)" : ""}`, MaxStaticBitrate: 999_999_999, MaxStreamingBitrate: 999_999_999, CodecProfiles: [ @@ -74,89 +75,8 @@ export const generateDeviceProfile = async () => { MaxAudioChannels: "2", }, ], - SubtitleProfiles: [ - // Official formats - { Format: "vtt", Method: "Embed" }, - { Format: "vtt", Method: "External" }, - - { Format: "webvtt", Method: "Embed" }, - { Format: "webvtt", Method: "External" }, - - { Format: "srt", Method: "Embed" }, - { Format: "srt", Method: "External" }, - - { Format: "subrip", Method: "Embed" }, - { Format: "subrip", Method: "External" }, - - { Format: "ttml", Method: "Embed" }, - { Format: "ttml", Method: "External" }, - - { Format: "dvbsub", Method: "Embed" }, - { Format: "dvdsub", Method: "Encode" }, - - { Format: "ass", Method: "Embed" }, - { Format: "ass", Method: "External" }, - - { Format: "idx", Method: "Embed" }, - { Format: "idx", Method: "Encode" }, - - { Format: "pgs", Method: "Embed" }, - { Format: "pgs", Method: "Encode" }, - - { Format: "pgssub", Method: "Embed" }, - { Format: "pgssub", Method: "Encode" }, - - { Format: "ssa", Method: "Embed" }, - { Format: "ssa", Method: "External" }, - - // Other formats - { Format: "microdvd", Method: "Embed" }, - { Format: "microdvd", Method: "External" }, - - { Format: "mov_text", Method: "Embed" }, - { Format: "mov_text", Method: "External" }, - - { Format: "mpl2", Method: "Embed" }, - { Format: "mpl2", Method: "External" }, - - { Format: "pjs", Method: "Embed" }, - { Format: "pjs", Method: "External" }, - - { Format: "realtext", Method: "Embed" }, - { Format: "realtext", Method: "External" }, - - { Format: "scc", Method: "Embed" }, - { Format: "scc", Method: "External" }, - - { Format: "smi", Method: "Embed" }, - { Format: "smi", Method: "External" }, - - { Format: "stl", Method: "Embed" }, - { Format: "stl", Method: "External" }, - - { Format: "sub", Method: "Embed" }, - { Format: "sub", Method: "External" }, - - { Format: "subviewer", Method: "Embed" }, - { Format: "subviewer", Method: "External" }, - - { Format: "teletext", Method: "Embed" }, - { Format: "teletext", Method: "Encode" }, - - { Format: "text", Method: "Embed" }, - { Format: "text", Method: "External" }, - - { Format: "vplayer", Method: "Embed" }, - { Format: "vplayer", Method: "External" }, - - { Format: "xsub", Method: "Embed" }, - { Format: "xsub", Method: "External" }, - ], + SubtitleProfiles: getSubtitleProfiles(transcode ? "hls" : "External"), }; return profile; }; - -export default async () => { - return await generateDeviceProfile(); -}; diff --git a/utils/profiles/subtitles.js b/utils/profiles/subtitles.js new file mode 100644 index 00000000..7defa380 --- /dev/null +++ b/utils/profiles/subtitles.js @@ -0,0 +1,56 @@ +/** + * 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/. + */ + +const COMMON_SUBTITLE_PROFILES = [ + // Official formats + + { Format: "dvdsub", Method: "Embed" }, + { Format: "dvdsub", Method: "Encode" }, + + { Format: "idx", Method: "Embed" }, + { Format: "idx", Method: "Encode" }, + + { Format: "pgs", Method: "Embed" }, + { Format: "pgs", Method: "Encode" }, + + { Format: "pgssub", Method: "Embed" }, + { Format: "pgssub", Method: "Encode" }, + + { Format: "teletext", Method: "Embed" }, + { Format: "teletext", Method: "Encode" }, +]; + +const VARYING_SUBTITLE_FORMATS = [ + "webvtt", + "vtt", + "srt", + "subrip", + "ttml", + "ass", + "ssa", + "microdvd", + "mov_text", + "mpl2", + "pjs", + "realtext", + "scc", + "smi", + "stl", + "sub", + "subviewer", + "text", + "vplayer", + "xsub", +]; + +export const getSubtitleProfiles = (secondaryMethod) => { + const profiles = [...COMMON_SUBTITLE_PROFILES]; + for (const format of VARYING_SUBTITLE_FORMATS) { + profiles.push({ Format: format, Method: "Embed" }); + profiles.push({ Format: format, Method: secondaryMethod }); + } + return profiles; +}; diff --git a/utils/segments.ts b/utils/segments.ts new file mode 100644 index 00000000..5c36de78 --- /dev/null +++ b/utils/segments.ts @@ -0,0 +1,114 @@ +import { Api } from "@jellyfin/sdk"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import { useDownload } from "@/providers/DownloadProvider"; +import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getAuthHeaders } from "./jellyfin/jellyfin"; + +interface IntroTimestamps { + EpisodeId: string; + HideSkipPromptAt: number; + IntroEnd: number; + IntroStart: number; + ShowSkipPromptAt: number; + Valid: boolean; +} + +interface CreditTimestamps { + Introduction: { + Start: number; + End: number; + Valid: boolean; + }; + Credits: { + Start: number; + End: number; + Valid: boolean; + }; +} + +export const useSegments = (itemId: string, isOffline: boolean) => { + const [api] = useAtom(apiAtom); + const { downloadedFiles } = useDownload(); + const downloadedItem = downloadedFiles?.find( + (d: DownloadedItem) => d.item.Id === itemId, + ); + + return useQuery({ + queryKey: ["segments", itemId, isOffline], + queryFn: async () => { + if (isOffline && downloadedItem) { + return getSegmentsForItem(downloadedItem); + } + if (!api) { + throw new Error("API client is not available"); + } + return fetchAndParseSegments(itemId, api); + }, + enabled: !!api, + }); +}; + +export const getSegmentsForItem = ( + item: DownloadedItem, +): { + introSegments: MediaTimeSegment[]; + creditSegments: MediaTimeSegment[]; +} => { + return { + introSegments: item.introSegments || [], + creditSegments: item.creditSegments || [], + }; +}; + +export const fetchAndParseSegments = async ( + itemId: string, + api: Api, +): Promise<{ + introSegments: MediaTimeSegment[]; + creditSegments: MediaTimeSegment[]; +}> => { + const introSegments: MediaTimeSegment[] = []; + const creditSegments: MediaTimeSegment[] = []; + + try { + const [introRes, creditRes] = await Promise.allSettled([ + api.axiosInstance.get( + `${api.basePath}/Episode/${itemId}/IntroTimestamps`, + { + headers: getAuthHeaders(api), + }, + ), + api.axiosInstance.get( + `${api.basePath}/Episode/${itemId}/Timestamps`, + { + headers: getAuthHeaders(api), + }, + ), + ]); + + if (introRes.status === "fulfilled" && introRes.value.data.Valid) { + introSegments.push({ + startTime: introRes.value.data.IntroStart, + endTime: introRes.value.data.IntroEnd, + text: "Intro", + }); + } + + if ( + creditRes.status === "fulfilled" && + creditRes.value.data.Credits.Valid + ) { + creditSegments.push({ + startTime: creditRes.value.data.Credits.Start, + endTime: creditRes.value.data.Credits.End, + text: "Credits", + }); + } + } catch (error) { + console.error("Failed to fetch segments", error); + } + + return { introSegments, creditSegments }; +};