fix: rn downloads

This commit is contained in:
Fredrik Burmester
2025-10-01 15:25:16 +02:00
parent 6fc4c33759
commit 32c01c6f89
12 changed files with 222 additions and 197 deletions

View File

@@ -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),
})),
},
];
<PlatformDropdown
groups={optionGroups}
title="Select Item" // Title here
// ...
/>
// 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

View File

@@ -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";

View File

@@ -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() {
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<GlobalModalProvider>
<SystemBars style='light' hidden={false} />
<GlobalModalProvider>
<BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
@@ -471,10 +467,10 @@ function Layout() {
}}
closeButton
/>
<GlobalModal />
</ThemeProvider>
<GlobalModal />
</GlobalModalProvider>
</BottomSheetModalProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>

View File

@@ -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'
>
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
<Text className='ml-2 text-purple-600'>
<Text className=' ml-1 text-purple-600'>
{t("login.change_server")}
</Text>
</TouchableOpacity>

View File

@@ -34,7 +34,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
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<Props> = ({
if (isTv) return null;
return (
<View
className='flex shrink'
style={{
minWidth: 50,
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.audio")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}}
>
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.audio")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
</View>
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

@@ -79,18 +79,16 @@ export const BitrateSelector: React.FC<Props> = ({
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<Props> = ({
if (isTv) return null;
return (
<View
className='flex shrink'
style={{
minWidth: 60,
maxWidth: 200,
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.quality")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}}
>
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.quality")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
</View>
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

@@ -359,16 +359,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
})}
</Text>
</View>
<View className='flex flex-col space-y-2 w-full items-start'>
<BitrateSelector
inverted
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/>
<View className='flex flex-col space-y-2 w-full'>
<View className='items-start'>
<BitrateSelector
inverted
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/>
</View>
{itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'>
<Text>{t("item_card.download.download_unwatched_only")}</Text>
@@ -380,21 +382,23 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)}
{itemsNotDownloaded.length === 1 && (
<View>
<MediaSourceSelector
item={items[0]}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/>
<View className='items-start'>
<MediaSourceSelector
item={items[0]}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/>
</View>
{selectedOptions?.mediaSource && (
<View className='flex flex-col space-y-2'>
<View className='flex flex-col space-y-2 items-start'>
<AudioTrackSelector
source={selectedOptions.mediaSource}
onChange={(val) => {
@@ -427,11 +431,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)}
</View>
<Button
className='mt-auto'
onPress={acceptDownloadOptions}
color='purple'
>
<Button onPress={acceptDownloadOptions} color='purple'>
{t("item_card.download.download_button")}
</Button>
</View>

View File

@@ -47,19 +47,17 @@ export const MediaSourceSelector: React.FC<Props> = ({
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<Props> = ({
if (isTv) return null;
return (
<View
className='flex shrink'
style={{
minWidth: 50,
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.video")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}}
>
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.video")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
</View>
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

@@ -33,29 +33,27 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
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<Props> = ({
if (Platform.isTV || subtitleStreams?.length === 0) return null;
return (
<View
className='flex col shrink justify-start place-self-start items-start'
style={{
minWidth: 60,
maxWidth: 200,
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.subtitles")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}}
>
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.subtitles")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
</View>
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

@@ -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;

View File

@@ -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<void> => {
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,

View File

@@ -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"