fix: design issues regarding downloads

This commit is contained in:
Fredrik Burmester
2024-12-08 16:29:17 +01:00
parent 5e9b28f2eb
commit 684e671750
8 changed files with 299 additions and 306 deletions

View File

@@ -2,7 +2,7 @@ import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
@@ -78,14 +78,28 @@ export default function page() {
}
}, [series]);
const deleteSeries = useCallback(
async () => deleteItems(groupBySeason),
[groupBySeason]
);
const deleteSeries = useCallback(() => {
Alert.alert(
"Delete season",
"Are you sure you want to delete the entire season?",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
onPress: () => deleteItems(groupBySeason),
style: "destructive",
},
]
);
}, [groupBySeason]);
return (
<View className="px-4 flex-1">
<View className="flex-1">
{series.length > 0 && (
<View className="flex flex-row items-center justify-start my-2">
<View className="flex flex-row items-center justify-start my-2 px-4">
<SeasonDropdown
item={series[0].item}
seasons={series.map((s) => s.item)}
@@ -101,18 +115,16 @@ export default function page() {
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
</View>
<View className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center ml-auto">
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name="trash" size={22} color="white" />
<Ionicons name="trash" size={20} color="white" />
</TouchableOpacity>
</View>
</View>
)}
<ScrollView key={seasonIndex}>
<ScrollView key={seasonIndex} className="px-4">
{groupBySeason.map((episode, index) => (
<View className="flex flex-col mb-4" key={index}>
<EpisodeCard item={episode} />
</View>
<EpisodeCard key={index} item={episode} />
))}
</ScrollView>
</View>

View File

@@ -18,7 +18,7 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import {Href, router, useFocusEffect} from "expo-router";
import { Href, router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
@@ -34,8 +34,8 @@ import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
interface DownloadProps extends ViewProps {
items: BaseItemDto[];
MissingDownloadIconComponent: () => React.ReactElement;
DownloadedIconComponent: () => React.ReactElement;
MissingDownloadIconComponent: () => React.ReactElement;
DownloadedIconComponent: () => React.ReactElement;
}
export const DownloadItems: React.FC<DownloadProps> = ({
@@ -51,16 +51,25 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4();
const [selectedMediaSource, setSelectedMediaSource] = useState<MediaSourceInfo | undefined | null>(undefined);
const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined | null
>(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] = useState<number>(0);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
const userCanDownload = useMemo(() => user?.Policy?.EnableContentDownloading, [user]);
const usingOptimizedServer = useMemo(() => settings?.downloadMethod === "optimized", [settings]);
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,
[user]
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === "optimized",
[settings]
);
/**
* Bottom sheet
@@ -78,73 +87,76 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}, []);
// region computed
const itemIds = useMemo(() => items.map(i => i.Id), [items]);
const pendingItems = useMemo(() =>
items.filter(i => !downloadedFiles?.some(f => f.item.Id === i.Id)),
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
const pendingItems = useMemo(
() =>
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
[items, downloadedFiles]
);
const isDownloaded = useMemo(() => {
if (!downloadedFiles)
return false;
if (!downloadedFiles) return false;
return pendingItems.length == 0;
}, [downloadedFiles, pendingItems]);
const itemsProcesses = useMemo(() =>
processes?.filter(p => itemIds.includes(p.item.Id)),
const itemsProcesses = useMemo(
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds]
);
const progress = useMemo(() => {
if (itemIds.length == 1)
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0)
return ((itemIds.length - queue.filter(q => itemIds.includes(q.item.Id)).length) / itemIds.length) * 100
},
[queue, itemsProcesses, itemIds]
);
if (itemIds.length == 1)
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
return (
((itemIds.length -
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
itemIds.length) *
100
);
}, [queue, itemsProcesses, itemIds]);
const itemsQueued = useMemo(() => {
return pendingItems.length > 0 && pendingItems.every(p => queue.some(q => p.Id == q.item.Id))
},
[queue, pendingItems]
);
return (
pendingItems.length > 0 &&
pendingItems.every((p) => queue.some((q) => p.Id == q.item.Id))
);
}, [queue, pendingItems]);
// endregion computed
// region helper functions
const navigateToDownloads = () => router.push("/downloads");
const onDownloadedPress = () => {
const firstItem = items?.[0]
const firstItem = items?.[0];
router.push(
firstItem.Type !== "Episode"
? "/downloads"
: {
pathname: `/downloads/${firstItem.SeriesId}`,
params: {
episodeSeasonIndex: firstItem.ParentIndexNumber
}
} as Href
: ({
pathname: `/downloads/${firstItem.SeriesId}`,
params: {
episodeSeasonIndex: firstItem.ParentIndexNumber,
},
} as Href)
);
}
};
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (pendingItems.some(i => !i.Id)) {
if (pendingItems.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
if (usingOptimizedServer)
initiateDownload(...pendingItems);
if (usingOptimizedServer) initiateDownload(...pendingItems);
else {
queueActions.enqueue(
queue,
setQueue,
...pendingItems.map(item => ({
...pendingItems.map((item) => ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
}))
)
);
}
} else {
toast.error("You are not allowed to download files.");
@@ -160,70 +172,83 @@ export const DownloadItems: React.FC<DownloadProps> = ({
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream
])
selectedSubtitleStream,
]);
/**
* Start download
*/
const initiateDownload = useCallback(async (...items: BaseItemDto[]) => {
if (!api || !user?.Id || items.some(p => !p.Id) || (pendingItems.length === 1 && !selectedMediaSource?.Id)) {
throw new Error("DownloadItem ~ initiateDownload: No api or user or item");
}
let mediaSource = selectedMediaSource
let audioIndex: number | undefined = selectedAudioStream
let subtitleIndex: number | undefined = selectedSubtitleStream
for (const item of items) {
if (pendingItems.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(item, settings!));
}
const res = await getStreamUrl({
api,
item,
startTimeTicks: 0,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: maxBitrate.value,
mediaSourceId: mediaSource?.Id,
subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
});
if (!res) {
Alert.alert(
"Something went wrong",
"Could not get stream url from Jellyfin"
const initiateDownload = useCallback(
async (...items: BaseItemDto[]) => {
if (
!api ||
!user?.Id ||
items.some((p) => !p.Id) ||
(pendingItems.length === 1 && !selectedMediaSource?.Id)
) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
);
continue;
}
let mediaSource = selectedMediaSource;
let audioIndex: number | undefined = selectedAudioStream;
let subtitleIndex: number | undefined = selectedSubtitleStream;
const {mediaSource: source, url} = res;
for (const item of items) {
if (pendingItems.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
item,
settings!
));
}
if (!url || !source) throw new Error("No url");
const res = await getStreamUrl({
api,
item,
startTimeTicks: 0,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: maxBitrate.value,
mediaSourceId: mediaSource?.Id,
subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
});
saveDownloadItemInfoToDiskTmp(item, source, url);
if (!res) {
Alert.alert(
"Something went wrong",
"Could not get stream url from Jellyfin"
);
continue;
}
if (usingOptimizedServer) {
await startBackgroundDownload(url, item, source);
} else {
await startRemuxing(item, url, source);
const { mediaSource: source, url } = res;
if (!url || !source) throw new Error("No url");
saveDownloadItemInfoToDiskTmp(item, source, url);
if (usingOptimizedServer) {
await startBackgroundDownload(url, item, source);
} else {
await startRemuxing(item, url, source);
}
}
}
}, [
api,
user?.Id,
pendingItems,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
settings,
maxBitrate,
usingOptimizedServer,
startBackgroundDownload,
startRemuxing,
]);
},
[
api,
user?.Id,
pendingItems,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
settings,
maxBitrate,
usingOptimizedServer,
startBackgroundDownload,
startRemuxing,
]
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -255,7 +280,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
return (
<View
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center"
{...props}
>
{processes && itemsProcesses.length > 0 ? (
@@ -343,7 +368,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
</Button>
<View className="opacity-70 text-center w-full flex items-center">
<Text className="text-xs">
{usingOptimizedServer ? "Using optimized server" : "Using default method"}
{usingOptimizedServer
? "Using optimized server"
: "Using default method"}
</Text>
</View>
</View>
@@ -353,7 +380,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
};
export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({
item,
}) => {
return (
<DownloadItems
items={[item]}
@@ -364,5 +393,5 @@ export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({ item }) =>
<Ionicons name="cloud-download" size={26} color="#9333ea" />
)}
/>
)
}
);
};

View File

@@ -1,6 +1,6 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import {DownloadSingleItem} from "@/components/DownloadItem";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlayButton } from "@/components/PlayButton";
@@ -228,6 +228,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<OverviewText text={item.Overview} className="px-4 my-4" />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<CastAndCrew item={item} className="mb-4" loading={loading} />
{item.People && item.People.length > 0 && (
@@ -243,10 +247,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View>
)}
{item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<SimilarItems itemId={item.Id} />
</>
)}

View File

@@ -1,62 +1,47 @@
import { Text } from "@/components/common/Text";
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, {useEffect, useMemo, useState} from "react";
import {Text} from "@/components/common/Text";
import useDownloadHelper from "@/utils/download";
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
import {TextProps} from "react-native";
import React, { useEffect, useMemo, useState } from "react";
import { TextProps } from "react-native";
interface DownloadSizeProps extends TextProps {
items: BaseItemDto[];
}
interface DownloadSizes {
knownSize: number;
itemsNeedingSize: BaseItemDto[];
}
export const DownloadSize: React.FC<DownloadSizeProps> = ({ items, ...props }) => {
const { downloadedFiles, saveDownloadedItemInfo } = useDownload();
const { getDownloadSize } = useDownloadHelper();
export const DownloadSize: React.FC<DownloadSizeProps> = ({
items,
...props
}) => {
const { downloadedFiles, getDownloadedItemSize } = useDownload();
const [size, setSize] = useState<string | undefined>();
const itemIds = useMemo(() => items.map(i => i.Id), [items])
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
useEffect(() => {
if (!downloadedFiles)
return
if (!downloadedFiles) return;
const {knownSize, itemsNeedingSize} = downloadedFiles
.filter(f => itemIds.includes(f.item.Id))
?.reduce<DownloadSizes>((acc, file) => {
if (file?.size && file.size > 0)
acc.knownSize += file.size
else
acc.itemsNeedingSize.push(file.item)
return acc
}, {
knownSize: 0,
itemsNeedingSize: []
})
let s = 0;
getDownloadSize(
(item, size) => saveDownloadedItemInfo(item, size),
...itemsNeedingSize
).then(sizeSum => {
setSize(bytesToReadable((sizeSum + knownSize)))
})
},
[items, itemIds]
);
for (const item of items) {
if (!item.Id) continue;
const size = getDownloadedItemSize(item.Id);
if (size) {
s += size;
}
}
setSize(bytesToReadable(s));
}, [itemIds]);
const sizeText = useMemo(() => {
if (!size)
return "reading size..."
return size
}, [size])
if (!size) return "...";
return size;
}, [size]);
return (
<>
<Text className="text-xs text-neutral-500" {...props}>{sizeText}</Text>
<Text className="text-xs text-neutral-500" {...props}>
{sizeText}
</Text>
</>
);
};
};

View File

@@ -1,31 +1,28 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import React, {useCallback, useMemo} from "react";
import { TouchableOpacity, View } from "react-native";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import {
ActionSheetProvider,
useActionSheet,
} from "@expo/react-native-action-sheet";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import {useDownload} from "@/providers/DownloadProvider";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Image } from "expo-image";
import { Ionicons } from "@expo/vector-icons";
import {Text} from "@/components/common/Text";
import {runtimeTicksToSeconds} from "@/utils/time";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import { Text } from "@/components/common/Text";
import { runtimeTicksToSeconds } from "@/utils/time";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
interface EpisodeCardProps {
interface EpisodeCardProps extends TouchableOpacityProps {
item: BaseItemDto;
}
/**
* EpisodeCard component displays an episode with action sheet options.
* @param {EpisodeCardProps} props - The component props.
* @returns {React.ReactElement} The rendered EpisodeCard component.
*/
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
@@ -77,35 +74,14 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
<TouchableOpacity
onPress={handleOpenFile}
onLongPress={showActionSheet}
className="flex flex-col mr-2"
key={item.Id}
className="flex flex-col mb-4"
>
<View className="flex flex-row items-start mb-2">
<View className="mr-2">
{base64Image ? (
<View className="w-44 aspect-video rounded-lg overflow-hidden">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
</View>
) : (
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
<Ionicons
name="image-outline"
size={24}
color="gray"
className="self-center mt-16"
/>
</View>
)}
<ContinueWatchingPoster size="small" item={item} useEpisodePoster />
</View>
<View className="w-56 flex flex-col">
<View className="shrink">
<Text numberOfLines={2} className="">
{item.Name}
</Text>
@@ -115,10 +91,12 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
<Text className="text-xs text-neutral-500">
{runtimeTicksToSeconds(item.RunTimeTicks)}
</Text>
<DownloadSize items={[item]} />
</View>
</View>
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">{item.Overview}</Text>
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
{item.Overview}
</Text>
</TouchableOpacity>
);
};

View File

@@ -112,7 +112,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
minHeight: 144 * nrOfEpisodes,
}}
>
<View className="flex flex-row justify-start items-center">
<View className="flex flex-row justify-start items-center px-4">
<SeasonDropdown
item={item}
seasons={seasons}
@@ -125,18 +125,19 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
}}
/>
<DownloadItems
className="ml-2"
items={episodes || []}
MissingDownloadIconComponent={() => (
<MaterialCommunityIcons
name="download-multiple"
size={24}
size={20}
color="white"
/>
)}
DownloadedIconComponent={() => (
<MaterialCommunityIcons
name="check-all"
size={24}
size={20}
color="#9333ea"
/>
)}

View File

@@ -1,6 +1,6 @@
import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import {useLog, writeToLog} from "@/utils/log";
import { useLog, writeToLog } from "@/utils/log";
import {
cancelAllJobs,
cancelJobById,
@@ -30,7 +30,7 @@ import {
import axios from "axios";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
import {atom, useAtom} from "jotai";
import { atom, useAtom } from "jotai";
import React, {
createContext,
useCallback,
@@ -47,16 +47,15 @@ import { getItemImage } from "@/utils/getItemImage";
import useImageStorage from "@/hooks/useImageStorage";
import { storage } from "@/utils/mmkv";
import useDownloadHelper from "@/utils/download";
import {FileInfo} from "expo-file-system";
import { FileInfo } from "expo-file-system";
import * as Haptics from "expo-haptics";
export type DownloadedItem = {
item: Partial<BaseItemDto>;
mediaSource: MediaSourceInfo;
size: number | undefined;
};
export const processesAtom = atom<JobStatus[]>([])
export const processesAtom = atom<JobStatus[]>([]);
function onAppStateChange(status: AppStateStatus) {
focusManager.setFocused(status === "active");
@@ -73,7 +72,7 @@ function useDownloadProvider() {
const [api] = useAtom(apiAtom);
const { logs } = useLog();
const {saveSeriesPrimaryImage} = useDownloadHelper();
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage();
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
@@ -267,7 +266,10 @@ function useDownloadProvider() {
);
})
.done(async (doneHandler) => {
await saveDownloadedItemInfo(process.item, doneHandler.bytesDownloaded);
await saveDownloadedItemInfo(
process.item,
doneHandler.bytesDownloaded
);
toast.success(`Download completed for ${process.item.Name}`, {
duration: 3000,
action: {
@@ -397,16 +399,21 @@ function useDownloadProvider() {
deleteLocalFiles(),
removeDownloadedItemsFromStorage(),
cancelAllServerJobs(),
queryClient.invalidateQueries({queryKey: ["downloadedItems"]}),
]).then(() =>
toast.success("All files, folders, and jobs deleted successfully")
).catch((reason) => {
console.error("Failed to delete all files, folders, and jobs:", reason);
toast.error("An error occurred while deleting files and jobs");
});
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
])
.then(() =>
toast.success("All files, folders, and jobs deleted successfully")
)
.catch((reason) => {
console.error("Failed to delete all files, folders, and jobs:", reason);
toast.error("An error occurred while deleting files and jobs");
});
};
const forEveryDirectoryFile = async (includeMMKV: boolean = true, callback: (file: FileInfo) => void) => {
const forEveryDirectoryFile = async (
includeMMKV: boolean = true,
callback: (file: FileInfo) => void
) => {
const baseDirectory = FileSystem.documentDirectory;
if (!baseDirectory) {
throw new Error("Base directory not found");
@@ -416,34 +423,29 @@ function useDownloadProvider() {
for (const item of dirContents) {
// Exclude mmkv directory.
// Deleting this deletes all user information as well. Logout should handle this.
if (item == "mmkv" && !includeMMKV)
continue
if (item == "mmkv" && !includeMMKV) continue;
const itemInfo = await FileSystem.getInfoAsync(`${baseDirectory}${item}`);
if (itemInfo.exists) {
callback(itemInfo)
callback(itemInfo);
}
}
}
};
const deleteLocalFiles = async (): Promise<void> => {
await forEveryDirectoryFile(false, (file) => {
console.warn("Deleting file", file.uri)
FileSystem.deleteAsync(file.uri, {idempotent: true})
}
)
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
})
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<void> => {
@@ -452,7 +454,7 @@ function useDownloadProvider() {
}
if (!settings?.optimizedVersionsServerUrl) {
console.error("No server URL configured");
return
return;
}
const deviceId = await getOrSetDeviceId();
@@ -513,41 +515,38 @@ function useDownloadProvider() {
};
const deleteItems = async (items: BaseItemDto[]) => {
Promise.all(items.map(i => {
if (i.Id)
return deleteFile(i.Id)
return
})).then(() =>
Promise.all(
items.map((i) => {
if (i.Id) return deleteFile(i.Id);
return;
})
).then(() =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
)
}
);
};
const deleteFileByType = async (type: BaseItemDto['Type']) => {
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
await Promise.all(
downloadedFiles
?.filter(file => file.item.Type == type)
?.flatMap(file => {
?.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!))
promises.push(deleteFile(file.item.SeriesId));
promises.push(deleteFile(file.item.Id!));
return promises;
})
|| []
}) || []
);
}
};
const appSizeUsage = useMemo(async () => {
const sizes: number[] = [];
await forEveryDirectoryFile(
true,
file => {
if (file.exists) sizes.push(file.size)
}
)
await forEveryDirectoryFile(true, (file) => {
if (file.exists) sizes.push(file.size);
});
return sizes.reduce((sum, size) => sum + size, 0);
}, [logs, downloadedFiles])
}, [logs, downloadedFiles]);
function getDownloadedItem(itemId: string): DownloadedItem | null {
try {
@@ -594,7 +593,7 @@ function useDownloadProvider() {
"Media source not found in tmp storage. Did you forget to save it before starting download?"
);
const newItem = { item, size, mediaSource: data.mediaSource };
const newItem = { item, mediaSource: data.mediaSource };
if (existingItemIndex !== -1) {
items[existingItemIndex] = newItem;
@@ -605,6 +604,8 @@ function useDownloadProvider() {
deleteDownloadItemInfoFromDiskTmp(item.Id!);
storage.set("downloadedItems", JSON.stringify(items));
storage.set("downloadedItemSize-" + item.Id, size.toString());
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
refetch();
} catch (error) {
@@ -615,6 +616,11 @@ function useDownloadProvider() {
}
}
function getDownloadedItemSize(itemId: string): number {
const size = storage.getString("downloadedItemSize-" + itemId);
return size ? parseInt(size) : 0;
}
return {
processes,
startBackgroundDownload,
@@ -628,7 +634,8 @@ function useDownloadProvider() {
startDownload,
getDownloadedItem,
deleteFileByType,
appSizeUsage
appSizeUsage,
getDownloadedItemSize,
};
}
@@ -653,15 +660,12 @@ export function useDownload() {
export function bytesToReadable(bytes: number): string {
const gb = bytes / 1e9;
if (gb >= 1)
return `${gb.toFixed(2)} GB`
if (gb >= 1) return `${gb.toFixed(2)} GB`;
const mb = bytes / 1024 / 1024
if (mb >= 1)
return `${mb.toFixed(2)} MB`
const mb = bytes / 1024 / 1024;
if (mb >= 1) return `${mb.toFixed(2)} MB`;
const kb = bytes / 1024
if (kb >= 1)
return `${kb.toFixed(2)} KB`
return `${bytes.toFixed(2)} B`
}
const kb = bytes / 1024;
if (kb >= 1) return `${kb.toFixed(2)} KB`;
return `${bytes.toFixed(2)} B`;
}

View File

@@ -1,49 +1,33 @@
import {getPrimaryImageUrlById} from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client";
import useImageStorage from "@/hooks/useImageStorage";
import {apiAtom} from "@/providers/JellyfinProvider";
import {useAtom} from "jotai";
import {storage} from "@/utils/mmkv";
import {getDownloadedFileUrl} from "@/hooks/useDownloadedFileOpener";
import * as FileSystem from 'expo-file-system';
import {FileInfo} from "expo-file-system";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { storage } from "@/utils/mmkv";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai";
const useDownloadHelper = () => {
const [api] = useAtom(apiAtom);
const {saveImage} = useImageStorage();
const { saveImage } = useImageStorage();
const saveSeriesPrimaryImage = async (item: BaseItemDto) => {
if (item.Type === "Episode" && item.SeriesId && !storage.getString(item.SeriesId)) {
await saveImage(item.SeriesId, getPrimaryImageUrlById({ api, id: item.SeriesId }))
console.log(`Attempting to save primary image for item: ${item.Id}`);
if (
item.Type === "Episode" &&
item.SeriesId &&
!storage.getString(item.SeriesId)
) {
console.log(`Saving primary image for series: ${item.SeriesId}`);
await saveImage(
item.SeriesId,
getPrimaryImageUrlById({ api, id: item.SeriesId })
);
console.log(`Primary image saved for series: ${item.SeriesId}`);
} else {
console.log(`Skipping primary image save for item: ${item.Id}`);
}
}
};
const getDownloadSize = async (
onNewItemSizeFetched: (item: BaseItemDto, size: number) => void,
...items: BaseItemDto[]
) => {
const sizes: number[] = [];
return { saveSeriesPrimaryImage };
};
await Promise.all(items.map(item => {
return new Promise(async (resolve, reject) => {
const url = await getDownloadedFileUrl(item.Id!);
if (url) {
const fileInfo: FileInfo = await FileSystem.getInfoAsync(url);
if (fileInfo.exists) {
onNewItemSizeFetched(item, fileInfo.size)
sizes.push(fileInfo.size);
resolve(sizes)
}
}
reject();
})
}));
return sizes.reduce((sum, size) => sum + size, 0);
}
return { saveSeriesPrimaryImage, getDownloadSize }
}
export default useDownloadHelper;
export default useDownloadHelper;