mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-03 20:48:26 +01:00
fix: rn downloads
This commit is contained in:
@@ -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
|
4. **Avoid nesting** - Don't show modals from within modals
|
||||||
5. **Consider UX** - Only use for important, contextual information that requires user attention
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
### Modal doesn't appear
|
### Modal doesn't appear
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as Sharing from "expo-sharing";
|
import * as Sharing from "expo-sharing";
|
||||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
|
|||||||
@@ -28,13 +28,9 @@ import {
|
|||||||
} from "@/utils/log";
|
} from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
// TEMPORARILY DISABLED
|
const BackGroundDownloader = !Platform.isTV
|
||||||
// To re-enable: Move package from "disabledDependencies" to "dependencies" in package.json,
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
// run "bun install", then uncomment the require below and remove the null assignment
|
: null;
|
||||||
// const BackGroundDownloader = !Platform.isTV
|
|
||||||
// ? require("@kesha-antonov/react-native-background-downloader")
|
|
||||||
// : null;
|
|
||||||
const BackGroundDownloader = null;
|
|
||||||
|
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 BackgroundTask from "expo-background-task";
|
||||||
|
|
||||||
import * as Device from "expo-device";
|
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;
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
|
|
||||||
@@ -149,7 +145,7 @@ if (!Platform.isTV) {
|
|||||||
|
|
||||||
const token = getTokenFromStorage();
|
const token = getTokenFromStorage();
|
||||||
const deviceId = getOrSetDeviceId();
|
const deviceId = getOrSetDeviceId();
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
const baseDirectory = Paths.document.uri;
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory)
|
if (!token || !deviceId || !baseDirectory)
|
||||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||||
@@ -426,10 +422,10 @@ function Layout() {
|
|||||||
<LogProvider>
|
<LogProvider>
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
<BottomSheetModalProvider>
|
<GlobalModalProvider>
|
||||||
<GlobalModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
|
<SystemBars style='light' hidden={false} />
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='(auth)/(tabs)'
|
name='(auth)/(tabs)'
|
||||||
@@ -471,10 +467,10 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
|
<GlobalModal />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
<GlobalModal />
|
</BottomSheetModalProvider>
|
||||||
</GlobalModalProvider>
|
</GlobalModalProvider>
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</DownloadProvider>
|
</DownloadProvider>
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
</LogProvider>
|
</LogProvider>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Image } from "expo-image";
|
|||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -82,10 +81,10 @@ const Login: React.FC = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
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} />
|
<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")}
|
{t("login.change_server")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
const optionGroups: OptionGroup[] = useMemo(
|
const optionGroups: OptionGroup[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
title: "Audio streams",
|
|
||||||
options:
|
options:
|
||||||
audioStreams?.map((audio, idx) => ({
|
audioStreams?.map((audio, idx) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
@@ -71,26 +70,19 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex shrink'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 50,
|
title={t("item_card.audio")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<PlatformDropdown
|
enablePanDownToClose: true,
|
||||||
groups={optionGroups}
|
}}
|
||||||
trigger={trigger}
|
/>
|
||||||
title={t("item_card.audio")}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
onOptionSelect={handleOptionSelect}
|
|
||||||
expoUIConfig={{
|
|
||||||
hostStyle: { flex: 1 },
|
|
||||||
}}
|
|
||||||
bottomSheetConfig={{
|
|
||||||
enablePanDownToClose: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,18 +79,16 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
const optionGroups: OptionGroup[] = useMemo(
|
const optionGroups: OptionGroup[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: "bitrates",
|
|
||||||
title: "Bitrates",
|
|
||||||
options: sorted.map((bitrate) => ({
|
options: sorted.map((bitrate) => ({
|
||||||
id: bitrate.key,
|
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
groupId: "bitrates",
|
|
||||||
label: bitrate.key,
|
label: bitrate.key,
|
||||||
|
value: bitrate,
|
||||||
selected: bitrate.value === selected?.value,
|
selected: bitrate.value === selected?.value,
|
||||||
|
onPress: () => onChange(bitrate),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[sorted, selected],
|
[sorted, selected, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOptionSelect = (optionId: string) => {
|
const handleOptionSelect = (optionId: string) => {
|
||||||
@@ -118,27 +116,19 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex shrink'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 60,
|
title={t("item_card.quality")}
|
||||||
maxWidth: 200,
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<PlatformDropdown
|
enablePanDownToClose: true,
|
||||||
groups={optionGroups}
|
}}
|
||||||
trigger={trigger}
|
/>
|
||||||
title={t("item_card.quality")}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
onOptionSelect={handleOptionSelect}
|
|
||||||
expoUIConfig={{
|
|
||||||
hostStyle: { flex: 1 },
|
|
||||||
}}
|
|
||||||
bottomSheetConfig={{
|
|
||||||
enablePanDownToClose: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -359,16 +359,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 w-full items-start'>
|
<View className='flex flex-col space-y-2 w-full'>
|
||||||
<BitrateSelector
|
<View className='items-start'>
|
||||||
inverted
|
<BitrateSelector
|
||||||
onChange={(val) =>
|
inverted
|
||||||
setSelectedOptions(
|
onChange={(val) =>
|
||||||
(prev) => prev && { ...prev, bitrate: val },
|
setSelectedOptions(
|
||||||
)
|
(prev) => prev && { ...prev, bitrate: val },
|
||||||
}
|
)
|
||||||
selected={selectedOptions?.bitrate}
|
}
|
||||||
/>
|
selected={selectedOptions?.bitrate}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
{itemsNotDownloaded.length > 1 && (
|
{itemsNotDownloaded.length > 1 && (
|
||||||
<View className='flex flex-row items-center justify-between w-full py-2'>
|
<View className='flex flex-row items-center justify-between w-full py-2'>
|
||||||
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
||||||
@@ -380,21 +382,23 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
{itemsNotDownloaded.length === 1 && (
|
{itemsNotDownloaded.length === 1 && (
|
||||||
<View>
|
<View>
|
||||||
<MediaSourceSelector
|
<View className='items-start'>
|
||||||
item={items[0]}
|
<MediaSourceSelector
|
||||||
onChange={(val) =>
|
item={items[0]}
|
||||||
setSelectedOptions(
|
onChange={(val) =>
|
||||||
(prev) =>
|
setSelectedOptions(
|
||||||
prev && {
|
(prev) =>
|
||||||
...prev,
|
prev && {
|
||||||
mediaSource: val,
|
...prev,
|
||||||
},
|
mediaSource: val,
|
||||||
)
|
},
|
||||||
}
|
)
|
||||||
selected={selectedOptions?.mediaSource}
|
}
|
||||||
/>
|
selected={selectedOptions?.mediaSource}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
{selectedOptions?.mediaSource && (
|
{selectedOptions?.mediaSource && (
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<AudioTrackSelector
|
<AudioTrackSelector
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
@@ -427,11 +431,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<Button onPress={acceptDownloadOptions} color='purple'>
|
||||||
className='mt-auto'
|
|
||||||
onPress={acceptDownloadOptions}
|
|
||||||
color='purple'
|
|
||||||
>
|
|
||||||
{t("item_card.download.download_button")}
|
{t("item_card.download.download_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -47,19 +47,17 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
const optionGroups: OptionGroup[] = useMemo(
|
const optionGroups: OptionGroup[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: "media-sources",
|
|
||||||
title: "Media sources",
|
|
||||||
options:
|
options:
|
||||||
item.MediaSources?.map((source, idx) => ({
|
item.MediaSources?.map((source) => ({
|
||||||
id: `${source.Id || idx}`,
|
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
groupId: "media-sources",
|
|
||||||
label: getDisplayName(source),
|
label: getDisplayName(source),
|
||||||
|
value: source,
|
||||||
selected: source.Id === selected?.Id,
|
selected: source.Id === selected?.Id,
|
||||||
|
onPress: () => onChange(source),
|
||||||
})) || [],
|
})) || [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[item.MediaSources, selected, getDisplayName],
|
[item.MediaSources, selected, getDisplayName, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOptionSelect = (optionId: string) => {
|
const handleOptionSelect = (optionId: string) => {
|
||||||
@@ -87,26 +85,19 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex shrink'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 50,
|
title={t("item_card.video")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<PlatformDropdown
|
enablePanDownToClose: true,
|
||||||
groups={optionGroups}
|
}}
|
||||||
trigger={trigger}
|
/>
|
||||||
title={t("item_card.video")}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
onOptionSelect={handleOptionSelect}
|
|
||||||
expoUIConfig={{
|
|
||||||
hostStyle: { flex: 1 },
|
|
||||||
}}
|
|
||||||
bottomSheetConfig={{
|
|
||||||
enablePanDownToClose: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,29 +33,27 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
const optionGroups: OptionGroup[] = useMemo(() => {
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||||
const options = [
|
const options = [
|
||||||
{
|
{
|
||||||
id: "none",
|
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
groupId: "subtitle-streams",
|
|
||||||
label: t("item_card.none"),
|
label: t("item_card.none"),
|
||||||
|
value: -1,
|
||||||
selected: selected === -1,
|
selected: selected === -1,
|
||||||
|
onPress: () => onChange(-1),
|
||||||
},
|
},
|
||||||
...(subtitleStreams?.map((subtitle, idx) => ({
|
...(subtitleStreams?.map((subtitle, idx) => ({
|
||||||
id: `${subtitle.Index || idx}`,
|
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
groupId: "subtitle-streams",
|
|
||||||
label: subtitle.DisplayTitle || `Subtitle Stream ${idx + 1}`,
|
label: subtitle.DisplayTitle || `Subtitle Stream ${idx + 1}`,
|
||||||
|
value: subtitle.Index,
|
||||||
selected: subtitle.Index === selected,
|
selected: subtitle.Index === selected,
|
||||||
|
onPress: () => onChange(subtitle.Index ?? -1),
|
||||||
})) || []),
|
})) || []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "subtitle-streams",
|
|
||||||
title: "Subtitle tracks",
|
|
||||||
options,
|
options,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [subtitleStreams, selected, t]);
|
}, [subtitleStreams, selected, t, onChange]);
|
||||||
|
|
||||||
const handleOptionSelect = (optionId: string) => {
|
const handleOptionSelect = (optionId: string) => {
|
||||||
if (optionId === "none") {
|
if (optionId === "none") {
|
||||||
@@ -96,27 +94,19 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
if (Platform.isTV || subtitleStreams?.length === 0) return null;
|
if (Platform.isTV || subtitleStreams?.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex col shrink justify-start place-self-start items-start'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 60,
|
title={t("item_card.subtitles")}
|
||||||
maxWidth: 200,
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<PlatformDropdown
|
enablePanDownToClose: true,
|
||||||
groups={optionGroups}
|
}}
|
||||||
trigger={trigger}
|
/>
|
||||||
title={t("item_card.subtitles")}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
onOptionSelect={handleOptionSelect}
|
|
||||||
expoUIConfig={{
|
|
||||||
hostStyle: { flex: 1 },
|
|
||||||
}}
|
|
||||||
bottomSheetConfig={{
|
|
||||||
enablePanDownToClose: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||||
const { getDefaultConfig } = require("expo/metro-config");
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
/** @type {import('expo/metro-config').MetroConfig} */
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef
|
const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef
|
||||||
@@ -23,6 +25,27 @@ if (process.env?.EXPO_TV === "1") {
|
|||||||
config.resolver.sourceExts = tvSourceExts;
|
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;
|
// config.resolver.unstable_enablePackageExports = false;
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Application from "expo-application";
|
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 * as Notifications from "expo-notifications";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { throttle } from "lodash";
|
import { throttle } from "lodash";
|
||||||
import React, {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
@@ -341,7 +341,10 @@ function useDownloadProvider() {
|
|||||||
return api?.accessToken;
|
return api?.accessToken;
|
||||||
}, [api]);
|
}, [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 getDownloadsDatabase = (): DownloadsDatabase => {
|
||||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||||
@@ -406,20 +409,17 @@ function useDownloadProvider() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filename = generateFilename(item);
|
const filename = generateFilename(item);
|
||||||
const trickplayDir = `${FileSystem.documentDirectory}${filename}_trickplay/`;
|
const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`);
|
||||||
await FileSystem.makeDirectoryAsync(trickplayDir, { intermediates: true });
|
trickplayDir.create({ intermediates: true });
|
||||||
let totalSize = 0;
|
let totalSize = 0;
|
||||||
|
|
||||||
for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
|
for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
|
||||||
const url = generateTrickplayUrl(item, index);
|
const url = generateTrickplayUrl(item, index);
|
||||||
if (!url) continue;
|
if (!url) continue;
|
||||||
const destination = `${trickplayDir}${index}.jpg`;
|
const destination = new File(trickplayDir, `${index}.jpg`);
|
||||||
try {
|
try {
|
||||||
await FileSystem.downloadAsync(url, destination);
|
await File.downloadFileAsync(url, destination);
|
||||||
const fileInfo = await FileSystem.getInfoAsync(destination);
|
totalSize += destination.size;
|
||||||
if (fileInfo.exists) {
|
|
||||||
totalSize += fileInfo.size;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`Failed to download trickplay image ${index} for item ${item.Id}`,
|
`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) => {
|
externalSubtitles.map(async (subtitle) => {
|
||||||
const url = api.basePath + subtitle.DeliveryUrl;
|
const url = api.basePath + subtitle.DeliveryUrl;
|
||||||
const filename = generateFilename(item);
|
const filename = generateFilename(item);
|
||||||
const destination = `${FileSystem.documentDirectory}${filename}_subtitle_${subtitle.Index}`;
|
const destination = new File(
|
||||||
await FileSystem.downloadAsync(url, destination);
|
Paths.document,
|
||||||
subtitle.DeliveryUrl = destination;
|
`${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 filename = generateFilename(process.item);
|
||||||
const videoFilePath = `${FileSystem.documentDirectory}${filename}.mp4`;
|
const videoFilePath = new File(Paths.document, `${filename}.mp4`).uri;
|
||||||
BackGroundDownloader?.download({
|
BackGroundDownloader?.download({
|
||||||
id: process.id,
|
id: process.id,
|
||||||
url: process.inputUrl,
|
url: process.inputUrl,
|
||||||
@@ -596,11 +599,11 @@ function useDownloadProvider() {
|
|||||||
)
|
)
|
||||||
.done(async () => {
|
.done(async () => {
|
||||||
const trickPlayData = await downloadTrickplayImages(process.item);
|
const trickPlayData = await downloadTrickplayImages(process.item);
|
||||||
const videoFileInfo = await FileSystem.getInfoAsync(videoFilePath);
|
const videoFile = new File(videoFilePath);
|
||||||
if (!videoFileInfo.exists) {
|
if (!videoFile.exists) {
|
||||||
throw new Error("Downloaded file does not exist");
|
throw new Error("Downloaded file does not exist");
|
||||||
}
|
}
|
||||||
const videoFileSize = videoFileInfo.size;
|
const videoFileSize = videoFile.size;
|
||||||
const db = getDownloadsDatabase();
|
const db = getDownloadsDatabase();
|
||||||
const { item, mediaSource } = process;
|
const { item, mediaSource } = process;
|
||||||
// Only download external subtitles for non-transcoded streams.
|
// Only download external subtitles for non-transcoded streams.
|
||||||
@@ -787,11 +790,12 @@ function useDownloadProvider() {
|
|||||||
*/
|
*/
|
||||||
const cleanCacheDirectory = async (): Promise<void> => {
|
const cleanCacheDirectory = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await FileSystem.deleteAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
if (APP_CACHE_DOWNLOAD_DIRECTORY.exists) {
|
||||||
idempotent: true,
|
APP_CACHE_DOWNLOAD_DIRECTORY.delete();
|
||||||
});
|
}
|
||||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
APP_CACHE_DOWNLOAD_DIRECTORY.create({
|
||||||
intermediates: true,
|
intermediates: true,
|
||||||
|
idempotent: true,
|
||||||
});
|
});
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory"));
|
toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory"));
|
||||||
@@ -898,9 +902,11 @@ function useDownloadProvider() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (downloadedItem?.videoFilePath) {
|
if (downloadedItem?.videoFilePath) {
|
||||||
await FileSystem.deleteAsync(downloadedItem.videoFilePath, {
|
try {
|
||||||
idempotent: true,
|
new File(downloadedItem.videoFilePath).delete();
|
||||||
});
|
} catch (_err) {
|
||||||
|
// File might not exist, ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (downloadedItem?.mediaSource?.MediaStreams) {
|
if (downloadedItem?.mediaSource?.MediaStreams) {
|
||||||
@@ -909,17 +915,21 @@ function useDownloadProvider() {
|
|||||||
stream.Type === "Subtitle" &&
|
stream.Type === "Subtitle" &&
|
||||||
stream.DeliveryMethod === "External"
|
stream.DeliveryMethod === "External"
|
||||||
) {
|
) {
|
||||||
await FileSystem.deleteAsync(stream.DeliveryUrl!, {
|
try {
|
||||||
idempotent: true,
|
new File(stream.DeliveryUrl!).delete();
|
||||||
});
|
} catch (_err) {
|
||||||
|
// File might not exist, ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (downloadedItem?.trickPlayData?.path) {
|
if (downloadedItem?.trickPlayData?.path) {
|
||||||
await FileSystem.deleteAsync(downloadedItem.trickPlayData.path, {
|
try {
|
||||||
idempotent: true,
|
new Directory(downloadedItem.trickPlayData.path).delete();
|
||||||
});
|
} catch (_err) {
|
||||||
|
// Directory might not exist, ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveDownloadsDatabase(db);
|
await saveDownloadsDatabase(db);
|
||||||
@@ -989,21 +999,17 @@ function useDownloadProvider() {
|
|||||||
* @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 appSizeUsage = async () => {
|
||||||
const [total, remaining] = await Promise.all([
|
const total = Paths.totalDiskSpace;
|
||||||
FileSystem.getTotalDiskCapacityAsync(),
|
const remaining = Paths.availableDiskSpace;
|
||||||
FileSystem.getFreeDiskStorageAsync(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let appSize = 0;
|
let appSize = 0;
|
||||||
const downloadedFiles = await FileSystem.readDirectoryAsync(
|
const documentDir = Paths.document;
|
||||||
`${FileSystem.documentDirectory!}`,
|
const contents = documentDir.list();
|
||||||
);
|
for (const item of contents) {
|
||||||
for (const file of downloadedFiles) {
|
if (item instanceof File) {
|
||||||
const fileInfo = await FileSystem.getInfoAsync(
|
appSize += item.size;
|
||||||
`${FileSystem.documentDirectory!}${file}`,
|
} else if (item instanceof Directory) {
|
||||||
);
|
appSize += item.size || 0;
|
||||||
if (fileInfo.exists) {
|
|
||||||
appSize += fileInfo.size;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { total, remaining, appSize: appSize };
|
return { total, remaining, appSize: appSize };
|
||||||
@@ -1208,7 +1214,7 @@ function useDownloadProvider() {
|
|||||||
deleteFileByType,
|
deleteFileByType,
|
||||||
getDownloadedItemSize,
|
getDownloadedItemSize,
|
||||||
getDownloadedItemById,
|
getDownloadedItemById,
|
||||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
APP_CACHE_DOWNLOAD_DIRECTORY: APP_CACHE_DOWNLOAD_DIRECTORY.uri,
|
||||||
cleanCacheDirectory,
|
cleanCacheDirectory,
|
||||||
updateDownloadedItem,
|
updateDownloadedItem,
|
||||||
appSizeUsage,
|
appSizeUsage,
|
||||||
|
|||||||
@@ -1523,9 +1523,9 @@
|
|||||||
"@jridgewell/resolve-uri" "^3.1.0"
|
"@jridgewell/resolve-uri" "^3.1.0"
|
||||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
"@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"
|
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":
|
"@nodelib/fs.scandir@2.1.5":
|
||||||
version "2.1.5"
|
version "2.1.5"
|
||||||
|
|||||||
Reference in New Issue
Block a user