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"