From 32c01c6f89f1dbfdabc54aeef3a8048def2055a2 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 1 Oct 2025 15:25:16 +0200 Subject: [PATCH] fix: rn downloads --- GLOBAL_MODAL_GUIDE.md | 39 ++++++++ .../(tabs)/(home)/settings/logs/page.tsx | 1 - app/_layout.tsx | 26 +++-- app/login.tsx | 5 +- components/AudioTrackSelector.tsx | 34 +++---- components/BitrateSelector.tsx | 42 +++----- components/DownloadItem.tsx | 58 +++++------ components/MediaSourceSelector.tsx | 43 ++++---- components/SubtitleTrackSelector.tsx | 46 ++++----- metro.config.js | 23 +++++ providers/DownloadProvider.tsx | 98 ++++++++++--------- yarn.lock | 4 +- 12 files changed, 222 insertions(+), 197 deletions(-) diff --git a/GLOBAL_MODAL_GUIDE.md b/GLOBAL_MODAL_GUIDE.md index 28e96cf5..5426ca72 100644 --- a/GLOBAL_MODAL_GUIDE.md +++ b/GLOBAL_MODAL_GUIDE.md @@ -176,6 +176,45 @@ The modal uses these default styles (can be overridden via options): 4. **Avoid nesting** - Don't show modals from within modals 5. **Consider UX** - Only use for important, contextual information that requires user attention +## Using with PlatformDropdown + +When using `PlatformDropdown` with option groups, avoid setting a `title` on the `OptionGroup` if you're already passing a `title` prop to `PlatformDropdown`. This prevents nested menu behavior on iOS where users have to click through an extra layer. + +```tsx +// Good - No title in option group (title is on PlatformDropdown) +const optionGroups: OptionGroup[] = [ + { + options: items.map((item) => ({ + type: "radio", + label: item.name, + value: item, + selected: item.id === selected?.id, + onPress: () => onChange(item), + })), + }, +]; + + + +// Bad - Causes nested menu on iOS +const optionGroups: OptionGroup[] = [ + { + title: "Items", // This creates a nested Picker on iOS + options: items.map((item) => ({ + type: "radio", + label: item.name, + value: item, + selected: item.id === selected?.id, + onPress: () => onChange(item), + })), + }, +]; +``` + ## Troubleshooting ### Modal doesn't appear diff --git a/app/(auth)/(tabs)/(home)/settings/logs/page.tsx b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx index 7af8fc03..d2b9b852 100644 --- a/app/(auth)/(tabs)/(home)/settings/logs/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx @@ -1,4 +1,3 @@ -import * as FileSystem from "expo-file-system"; import { useNavigation } from "expo-router"; import * as Sharing from "expo-sharing"; import { useCallback, useEffect, useId, useMemo, useState } from "react"; diff --git a/app/_layout.tsx b/app/_layout.tsx index 9a864846..42568998 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -28,13 +28,9 @@ import { } from "@/utils/log"; import { storage } from "@/utils/mmkv"; -// TEMPORARILY DISABLED -// To re-enable: Move package from "disabledDependencies" to "dependencies" in package.json, -// run "bun install", then uncomment the require below and remove the null assignment -// const BackGroundDownloader = !Platform.isTV -// ? require("@kesha-antonov/react-native-background-downloader") -// : null; -const BackGroundDownloader = null; +const BackGroundDownloader = !Platform.isTV + ? require("@kesha-antonov/react-native-background-downloader") + : null; import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -42,7 +38,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as BackgroundTask from "expo-background-task"; import * as Device from "expo-device"; -import * as FileSystem from "expo-file-system"; +import { Paths } from "expo-file-system"; const Notifications = !Platform.isTV ? require("expo-notifications") : null; @@ -149,7 +145,7 @@ if (!Platform.isTV) { const token = getTokenFromStorage(); const deviceId = getOrSetDeviceId(); - const baseDirectory = FileSystem.documentDirectory; + const baseDirectory = Paths.document.uri; if (!token || !deviceId || !baseDirectory) return BackgroundTask.BackgroundTaskResult.Failed; @@ -426,10 +422,10 @@ function Layout() { - - - + + diff --git a/app/login.tsx b/app/login.tsx index d64516b6..dc022d8e 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -4,7 +4,6 @@ import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { t } from "i18next"; import { useAtomValue } from "jotai"; -import type React from "react"; import { useCallback, useEffect, useState } from "react"; import { Alert, @@ -82,10 +81,10 @@ const Login: React.FC = () => { onPress={() => { removeServer(); }} - className='flex flex-row items-center' + className='flex flex-row items-center pr-2 pl-1' > - + {t("login.change_server")} diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index 05fd2156..3a3f9726 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -34,7 +34,6 @@ export const AudioTrackSelector: React.FC = ({ const optionGroups: OptionGroup[] = useMemo( () => [ { - title: "Audio streams", options: audioStreams?.map((audio, idx) => ({ type: "radio" as const, @@ -71,26 +70,19 @@ export const AudioTrackSelector: React.FC = ({ if (isTv) return null; return ( - - - + bottomSheetConfig={{ + enablePanDownToClose: true, + }} + /> ); }; diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index df1f383a..26d6e2c4 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -79,18 +79,16 @@ export const BitrateSelector: React.FC = ({ const optionGroups: OptionGroup[] = useMemo( () => [ { - id: "bitrates", - title: "Bitrates", options: sorted.map((bitrate) => ({ - id: bitrate.key, type: "radio" as const, - groupId: "bitrates", label: bitrate.key, + value: bitrate, selected: bitrate.value === selected?.value, + onPress: () => onChange(bitrate), })), }, ], - [sorted, selected], + [sorted, selected, onChange], ); const handleOptionSelect = (optionId: string) => { @@ -118,27 +116,19 @@ export const BitrateSelector: React.FC = ({ if (isTv) return null; return ( - - - + bottomSheetConfig={{ + enablePanDownToClose: true, + }} + /> ); }; diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 80ead77a..3e96a181 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -359,16 +359,18 @@ export const DownloadItems: React.FC = ({ })} - - - setSelectedOptions( - (prev) => prev && { ...prev, bitrate: val }, - ) - } - selected={selectedOptions?.bitrate} - /> + + + + setSelectedOptions( + (prev) => prev && { ...prev, bitrate: val }, + ) + } + selected={selectedOptions?.bitrate} + /> + {itemsNotDownloaded.length > 1 && ( {t("item_card.download.download_unwatched_only")} @@ -380,21 +382,23 @@ export const DownloadItems: React.FC = ({ )} {itemsNotDownloaded.length === 1 && ( - - setSelectedOptions( - (prev) => - prev && { - ...prev, - mediaSource: val, - }, - ) - } - selected={selectedOptions?.mediaSource} - /> + + + setSelectedOptions( + (prev) => + prev && { + ...prev, + mediaSource: val, + }, + ) + } + selected={selectedOptions?.mediaSource} + /> + {selectedOptions?.mediaSource && ( - + { @@ -427,11 +431,7 @@ export const DownloadItems: React.FC = ({ )} - diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index 6c6866d3..70f397f6 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -47,19 +47,17 @@ export const MediaSourceSelector: React.FC = ({ const optionGroups: OptionGroup[] = useMemo( () => [ { - id: "media-sources", - title: "Media sources", options: - item.MediaSources?.map((source, idx) => ({ - id: `${source.Id || idx}`, + item.MediaSources?.map((source) => ({ type: "radio" as const, - groupId: "media-sources", label: getDisplayName(source), + value: source, selected: source.Id === selected?.Id, + onPress: () => onChange(source), })) || [], }, ], - [item.MediaSources, selected, getDisplayName], + [item.MediaSources, selected, getDisplayName, onChange], ); const handleOptionSelect = (optionId: string) => { @@ -87,26 +85,19 @@ export const MediaSourceSelector: React.FC = ({ if (isTv) return null; return ( - - - + bottomSheetConfig={{ + enablePanDownToClose: true, + }} + /> ); }; diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index ac32569b..6fca1955 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -33,29 +33,27 @@ export const SubtitleTrackSelector: React.FC = ({ const optionGroups: OptionGroup[] = useMemo(() => { const options = [ { - id: "none", type: "radio" as const, - groupId: "subtitle-streams", label: t("item_card.none"), + value: -1, selected: selected === -1, + onPress: () => onChange(-1), }, ...(subtitleStreams?.map((subtitle, idx) => ({ - id: `${subtitle.Index || idx}`, type: "radio" as const, - groupId: "subtitle-streams", label: subtitle.DisplayTitle || `Subtitle Stream ${idx + 1}`, + value: subtitle.Index, selected: subtitle.Index === selected, + onPress: () => onChange(subtitle.Index ?? -1), })) || []), ]; return [ { - id: "subtitle-streams", - title: "Subtitle tracks", options, }, ]; - }, [subtitleStreams, selected, t]); + }, [subtitleStreams, selected, t, onChange]); const handleOptionSelect = (optionId: string) => { if (optionId === "none") { @@ -96,27 +94,19 @@ export const SubtitleTrackSelector: React.FC = ({ if (Platform.isTV || subtitleStreams?.length === 0) return null; return ( - - - + bottomSheetConfig={{ + enablePanDownToClose: true, + }} + /> ); }; diff --git a/metro.config.js b/metro.config.js index 2e9a0e06..adc6dd18 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,5 +1,7 @@ // Learn more https://docs.expo.io/guides/customizing-metro const { getDefaultConfig } = require("expo/metro-config"); +const path = require("node:path"); +const fs = require("node:fs"); /** @type {import('expo/metro-config').MetroConfig} */ const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef @@ -23,6 +25,27 @@ if (process.env?.EXPO_TV === "1") { config.resolver.sourceExts = tvSourceExts; } +// Support for symlinked packages (yarn link) - only if the directory exists +const linkedPackagePath = path.resolve( + __dirname, + "../react-native-background-downloader", +); + +if (fs.existsSync(linkedPackagePath)) { + console.log("Detected symlinked package, configuring Metro to support it"); + + // Watch the linked package directory + config.watchFolders = [linkedPackagePath]; + + // Add the parent directory to node module paths + config.resolver.nodeModulesPaths = [path.resolve(__dirname, "node_modules")]; + + // Map the package to the local directory + config.resolver.extraNodeModules = { + "@kesha-antonov/react-native-background-downloader": linkedPackagePath, + }; +} + // config.resolver.unstable_enablePackageExports = false; module.exports = config; diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 4306b8d6..c4d7d663 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -3,12 +3,12 @@ import type { MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; import * as Application from "expo-application"; -import * as FileSystem from "expo-file-system"; +import { Directory, File, Paths } from "expo-file-system"; import * as Notifications from "expo-notifications"; import { router } from "expo-router"; import { atom, useAtom } from "jotai"; import { throttle } from "lodash"; -import React, { +import { createContext, useCallback, useContext, @@ -341,7 +341,10 @@ function useDownloadProvider() { return api?.accessToken; }, [api]); - const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`; + const APP_CACHE_DOWNLOAD_DIRECTORY = new Directory( + Paths.cache, + `${Application.applicationId}/Downloads/`, + ); const getDownloadsDatabase = (): DownloadsDatabase => { const file = storage.getString(DOWNLOADS_DATABASE_KEY); @@ -406,20 +409,17 @@ function useDownloadProvider() { } const filename = generateFilename(item); - const trickplayDir = `${FileSystem.documentDirectory}${filename}_trickplay/`; - await FileSystem.makeDirectoryAsync(trickplayDir, { intermediates: true }); + const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`); + trickplayDir.create({ 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`; + const destination = new File(trickplayDir, `${index}.jpg`); try { - await FileSystem.downloadAsync(url, destination); - const fileInfo = await FileSystem.getInfoAsync(destination); - if (fileInfo.exists) { - totalSize += fileInfo.size; - } + await File.downloadFileAsync(url, destination); + totalSize += destination.size; } catch (e) { console.error( `Failed to download trickplay image ${index} for item ${item.Id}`, @@ -428,7 +428,7 @@ function useDownloadProvider() { } } - return { path: trickplayDir, size: totalSize }; + return { path: trickplayDir.uri, size: totalSize }; }; /** @@ -448,9 +448,12 @@ function useDownloadProvider() { 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; + const destination = new File( + Paths.document, + `${filename}_subtitle_${subtitle.Index}`, + ); + await File.downloadFileAsync(url, destination); + subtitle.DeliveryUrl = destination.uri; }), ); } @@ -544,7 +547,7 @@ function useDownloadProvider() { }, }); const filename = generateFilename(process.item); - const videoFilePath = `${FileSystem.documentDirectory}${filename}.mp4`; + const videoFilePath = new File(Paths.document, `${filename}.mp4`).uri; BackGroundDownloader?.download({ id: process.id, url: process.inputUrl, @@ -596,11 +599,11 @@ function useDownloadProvider() { ) .done(async () => { const trickPlayData = await downloadTrickplayImages(process.item); - const videoFileInfo = await FileSystem.getInfoAsync(videoFilePath); - if (!videoFileInfo.exists) { + const videoFile = new File(videoFilePath); + if (!videoFile.exists) { throw new Error("Downloaded file does not exist"); } - const videoFileSize = videoFileInfo.size; + const videoFileSize = videoFile.size; const db = getDownloadsDatabase(); const { item, mediaSource } = process; // Only download external subtitles for non-transcoded streams. @@ -787,11 +790,12 @@ function useDownloadProvider() { */ const cleanCacheDirectory = async (): Promise => { try { - await FileSystem.deleteAsync(APP_CACHE_DOWNLOAD_DIRECTORY, { - idempotent: true, - }); - await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, { + if (APP_CACHE_DOWNLOAD_DIRECTORY.exists) { + APP_CACHE_DOWNLOAD_DIRECTORY.delete(); + } + APP_CACHE_DOWNLOAD_DIRECTORY.create({ intermediates: true, + idempotent: true, }); } catch (_error) { toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory")); @@ -898,9 +902,11 @@ function useDownloadProvider() { } if (downloadedItem?.videoFilePath) { - await FileSystem.deleteAsync(downloadedItem.videoFilePath, { - idempotent: true, - }); + try { + new File(downloadedItem.videoFilePath).delete(); + } catch (_err) { + // File might not exist, ignore + } } if (downloadedItem?.mediaSource?.MediaStreams) { @@ -909,17 +915,21 @@ function useDownloadProvider() { stream.Type === "Subtitle" && stream.DeliveryMethod === "External" ) { - await FileSystem.deleteAsync(stream.DeliveryUrl!, { - idempotent: true, - }); + try { + new File(stream.DeliveryUrl!).delete(); + } catch (_err) { + // File might not exist, ignore + } } } } if (downloadedItem?.trickPlayData?.path) { - await FileSystem.deleteAsync(downloadedItem.trickPlayData.path, { - idempotent: true, - }); + try { + new Directory(downloadedItem.trickPlayData.path).delete(); + } catch (_err) { + // Directory might not exist, ignore + } } await saveDownloadsDatabase(db); @@ -989,21 +999,17 @@ function useDownloadProvider() { * @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(), - ]); + const total = Paths.totalDiskSpace; + const remaining = Paths.availableDiskSpace; 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; + const documentDir = Paths.document; + const contents = documentDir.list(); + for (const item of contents) { + if (item instanceof File) { + appSize += item.size; + } else if (item instanceof Directory) { + appSize += item.size || 0; } } return { total, remaining, appSize: appSize }; @@ -1208,7 +1214,7 @@ function useDownloadProvider() { deleteFileByType, getDownloadedItemSize, getDownloadedItemById, - APP_CACHE_DOWNLOAD_DIRECTORY, + APP_CACHE_DOWNLOAD_DIRECTORY: APP_CACHE_DOWNLOAD_DIRECTORY.uri, cleanCacheDirectory, updateDownloadedItem, appSizeUsage, diff --git a/yarn.lock b/yarn.lock index 774b0760..c9be2bfa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1523,9 +1523,9 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@kesha-antonov/react-native-background-downloader@github:fredrikburmester/react-native-background-downloader#f3bf69ad124b6ec6adbc30c7f688935d0376fc56": +"@kesha-antonov/react-native-background-downloader@github:fredrikburmester/react-native-background-downloader#dce095fd2a5f257ab3e2e49252fbe206ad994202": version "3.2.6" - resolved "https://codeload.github.com/fredrikburmester/react-native-background-downloader/tar.gz/f3bf69ad124b6ec6adbc30c7f688935d0376fc56" + resolved "https://codeload.github.com/fredrikburmester/react-native-background-downloader/tar.gz/dce095fd2a5f257ab3e2e49252fbe206ad994202" "@nodelib/fs.scandir@2.1.5": version "2.1.5"