diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx
index d50aa586..c9596647 100644
--- a/app/(auth)/(tabs)/(home)/downloads.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads.tsx
@@ -1,9 +1,10 @@
import { Text } from "@/components/common/Text";
-import { ActiveDownload } from "@/components/downloads/ActiveDownload";
+import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
+import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as FileSystem from "expo-file-system";
@@ -16,14 +17,14 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloads: React.FC = () => {
const [queue, setQueue] = useAtom(queueAtom);
const {
- clearProcess,
- process,
- readProcess,
startBackgroundDownload,
updateProcess,
+ removeProcess,
downloadedFiles,
} = useDownload();
+ const [settings] = useSettings();
+
const movies = useMemo(
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
[downloadedFiles]
@@ -51,46 +52,48 @@ const downloads: React.FC = () => {
>
-
- Queue
-
- {queue.map((q) => (
-
- router.push(`/(auth)/items/page?id=${q.item.Id}`)
- }
- className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
- >
-
- {q.item.Name}
- {q.item.Type}
-
+ {settings?.downloadMethod === "remux" && (
+
+ Queue
+
+ {queue.map((q) => (
{
- clearProcess();
- setQueue((prev) => {
- if (!prev) return [];
- return [...prev.filter((i) => i.id !== q.id)];
- });
- }}
+ onPress={() =>
+ router.push(`/(auth)/items/page?id=${q.item.Id}`)
+ }
+ className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
-
+
+ {q.item.Name}
+ {q.item.Type}
+
+ {
+ removeProcess(q.id);
+ setQueue((prev) => {
+ if (!prev) return [];
+ return [...prev.filter((i) => i.id !== q.id)];
+ });
+ }}
+ >
+
+
-
- ))}
+ ))}
+
+
+ {queue.length === 0 && (
+ No items in queue
+ )}
+ )}
- {queue.length === 0 && (
- No items in queue
- )}
-
-
-
+
{movies.length > 0 && (
- Movies
+ Movies
{movies?.length}
@@ -105,6 +108,11 @@ const downloads: React.FC = () => {
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
))}
+ {downloadedFiles?.length === 0 && (
+
+ No downloaded items
+
+ )}
);
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index 83b74da7..589b757f 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -40,7 +40,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
- const { process, startBackgroundDownload } = useDownload();
+ const { processes, startBackgroundDownload } = useDownload();
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(item);
const [selectedMediaSource, setSelectedMediaSource] =
@@ -188,6 +188,12 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
[]
);
+ const process = useMemo(() => {
+ if (!processes) return null;
+
+ return processes.find((process) => process.item.Id === item.Id);
+ }, [processes, item.Id]);
+
return (
= ({ ...props }) => {
- const router = useRouter();
- const { clearProcess, process } = useDownload();
- const [settings] = useSettings();
-
- const cancelJobMutation = useMutation({
- mutationFn: async () => {
- if (!process) throw new Error("No active download");
-
- if (settings?.downloadMethod === "optimized") {
- await axios.delete(
- settings?.optimizedVersionsServerUrl + "cancel-job/" + process.id,
- {
- headers: {
- Authorization: `Bearer ${settings?.optimizedVersionsAuthHeader}`,
- },
- }
- );
- const tasks = await checkForExistingDownloads();
- for (const task of tasks) task.stop();
- clearProcess();
- } else {
- FFmpegKit.cancel();
- clearProcess();
- }
- },
- onSuccess: () => {
- toast.success("Download canceled");
- },
- onError: (e) => {
- console.log(e);
- toast.error("Failed to cancel download");
- clearProcess();
- },
- });
-
- if (!process)
- return (
-
- Active download
- No active downloads
-
- );
-
- return (
-
- Active download
- router.push(`/(auth)/items/page?id=${process.item.Id}`)}
- className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
- >
-
-
-
- {process.item.Name}
- {process.item.Type}
-
- {process.progress.toFixed(0)}%
-
-
- {process.state}
-
-
- cancelJobMutation.mutate()}>
-
-
-
-
-
- );
-};
diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx
new file mode 100644
index 00000000..9154b8c4
--- /dev/null
+++ b/components/downloads/ActiveDownloads.tsx
@@ -0,0 +1,101 @@
+import { TouchableOpacity, View, ViewProps } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useRouter } from "expo-router";
+import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
+import { Ionicons } from "@expo/vector-icons";
+import { useEffect, useState } from "react";
+import { useDownload } from "@/providers/DownloadProvider";
+import { useMutation } from "@tanstack/react-query";
+import axios from "axios";
+import { toast } from "sonner-native";
+import { useSettings } from "@/utils/atoms/settings";
+import { FFmpegKit } from "ffmpeg-kit-react-native";
+
+interface Props extends ViewProps {}
+
+export const ActiveDownloads: React.FC = ({ ...props }) => {
+ const router = useRouter();
+ const { removeProcess, processes } = useDownload();
+ const [settings] = useSettings();
+
+ const cancelJobMutation = useMutation({
+ mutationFn: async (id: string) => {
+ if (!process) throw new Error("No active download");
+
+ try {
+ if (settings?.downloadMethod === "optimized") {
+ await axios.delete(
+ settings?.optimizedVersionsServerUrl + "cancel-job/" + id,
+ {
+ headers: {
+ Authorization: `Bearer ${settings?.optimizedVersionsAuthHeader}`,
+ },
+ }
+ );
+ const tasks = await checkForExistingDownloads();
+ for (const task of tasks) task.stop();
+ } else {
+ FFmpegKit.cancel();
+ }
+ } catch (e) {
+ throw e;
+ } finally {
+ removeProcess(id);
+ }
+ },
+ onSuccess: () => {
+ toast.success("Download canceled");
+ },
+ onError: (e) => {
+ console.log(e);
+ toast.error("Failed to cancel download");
+ },
+ });
+
+ if (processes.length === 0)
+ return (
+
+ Active download
+ No active downloads
+
+ );
+
+ return (
+
+ Active downloads
+
+ {processes.map((p) => (
+ router.push(`/(auth)/items/page?id=${p.item.Id}`)}
+ className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
+ >
+
+
+
+ {p.item.Name}
+ {p.item.Type}
+
+ {p.progress.toFixed(0)}%
+
+
+ {p.state}
+
+
+ cancelJobMutation.mutate(p.id)}>
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/components/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx
index c010ca04..bd057a0b 100644
--- a/components/downloads/SeriesCard.tsx
+++ b/components/downloads/SeriesCard.tsx
@@ -25,7 +25,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
return (
- {items[0].SeriesName}
+ {items[0].SeriesName}
{items.length}
diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts
index bcecd86b..9ec784b2 100644
--- a/hooks/useRemuxHlsToMp4.ts
+++ b/hooks/useRemuxHlsToMp4.ts
@@ -19,7 +19,7 @@ import { useRouter } from "expo-router";
*/
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
const queryClient = useQueryClient();
- const { process, updateProcess, clearProcess, saveDownloadedItemInfo } =
+ const { clearProcesses, saveDownloadedItemInfo, addProcess, updateProcess } =
useDownload();
const router = useRouter();
@@ -52,7 +52,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
);
try {
- updateProcess({
+ addProcess({
id: item.Id,
item,
progress: 0,
@@ -71,12 +71,9 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
- updateProcess((prev) => {
- if (!prev) return null;
- return {
- ...prev,
- progress: percentage,
- };
+ if (!item.Id) throw new Error("Item is undefined");
+ updateProcess(item.Id, {
+ progress: percentage,
});
});
@@ -114,7 +111,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
resolve();
}
- clearProcess();
+ clearProcesses();
} catch (error) {
reject(error);
}
@@ -126,17 +123,17 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
);
- clearProcess();
+ clearProcesses();
throw error; // Re-throw the error to propagate it to the caller
}
},
- [output, item, clearProcess]
+ [output, item, clearProcesses]
);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
- clearProcess();
- }, [item.Name, clearProcess]);
+ clearProcesses();
+ }, [item.Name, clearProcesses]);
return { startRemuxing, cancelRemuxing };
};
diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx
index 4c858ca7..4461de4d 100644
--- a/providers/DownloadProvider.tsx
+++ b/providers/DownloadProvider.tsx
@@ -30,10 +30,16 @@ export type ProcessItem = {
item: Partial;
progress: number;
size?: number;
- state: "optimizing" | "downloading" | "done" | "error" | "canceled";
+ state:
+ | "optimizing"
+ | "downloading"
+ | "done"
+ | "error"
+ | "canceled"
+ | "queued";
};
-const STORAGE_KEY = "runningProcess";
+const STORAGE_KEY = "runningProcesses";
const DownloadContext = createContext(null);
+ const [processes, setProcesses] = useState([]);
const [settings] = useSettings();
const router = useRouter();
const authHeader = useMemo(() => {
@@ -59,178 +65,147 @@ function useDownloadProvider() {
});
useEffect(() => {
- // Load initial process state from AsyncStorage
- const loadInitialProcess = async () => {
- const storedProcess = await readProcess();
- setProcess(storedProcess);
+ // Load initial processes state from AsyncStorage
+ const loadInitialProcesses = async () => {
+ const storedProcesses = await readProcesses();
+ setProcesses(storedProcesses);
};
- loadInitialProcess();
+ loadInitialProcesses();
}, []);
- const clearProcess = useCallback(async () => {
+ const clearProcesses = useCallback(async () => {
await AsyncStorage.removeItem(STORAGE_KEY);
- setProcess(null);
+ setProcesses([]);
}, []);
const updateProcess = useCallback(
- async (
- itemOrUpdater:
- | ProcessItem
- | null
- | ((prevState: ProcessItem | null) => ProcessItem | null)
- ) => {
- setProcess((prevProcess) => {
- let newState: ProcessItem | null;
- if (typeof itemOrUpdater === "function") {
- newState = itemOrUpdater(prevProcess);
- } else {
- newState = itemOrUpdater;
- }
+ async (id: string, updater: Partial) => {
+ setProcesses((prevProcesses) => {
+ const newProcesses = prevProcesses.map((process) =>
+ process.id === id ? { ...process, ...updater } : process
+ );
- if (newState === null) {
- AsyncStorage.removeItem(STORAGE_KEY);
- } else {
- AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
- }
+ AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses));
- return newState;
+ return newProcesses;
});
},
[]
);
- const readProcess = useCallback(async (): Promise => {
- const item = await AsyncStorage.getItem(STORAGE_KEY);
- return item ? JSON.parse(item) : null;
+ const addProcess = useCallback(async (item: ProcessItem) => {
+ setProcesses((prevProcesses) => {
+ const newProcesses = [...prevProcesses, item];
+ AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses));
+ return newProcesses;
+ });
}, []);
- const startDownload = useCallback(() => {
- if (!process?.item.Id) throw new Error("No item id");
+ const removeProcess = useCallback(async (id: string) => {
+ setProcesses((prevProcesses) => {
+ const newProcesses = prevProcesses.filter((process) => process.id !== id);
+ AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses));
+ return newProcesses;
+ });
+ }, []);
- download({
- id: process.id,
- url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
- destination: `${directories.documents}/${process?.item.Id}.mp4`,
- headers: {
- Authorization: authHeader,
- },
- })
- .begin(() => {
- toast.info(`Download started for ${process.item.Name}`);
- updateProcess((prev) => {
- if (!prev) return null;
- return {
- ...prev,
- state: "downloading",
- progress: 50,
- } as ProcessItem;
- });
+ const readProcesses = useCallback(async (): Promise => {
+ const items = await AsyncStorage.getItem(STORAGE_KEY);
+ return items ? JSON.parse(items) : [];
+ }, []);
+
+ const startDownload = useCallback(
+ (process: ProcessItem) => {
+ if (!process?.item.Id) throw new Error("No item id");
+
+ download({
+ id: process.id,
+ url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
+ destination: `${directories.documents}/${process?.item.Id}.mp4`,
+ headers: {
+ Authorization: authHeader,
+ },
})
- .progress((data) => {
- const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
- updateProcess((prev) => {
- if (!prev) {
- console.warn("no prev");
- return null;
- }
- return {
- ...prev,
+ .begin(() => {
+ toast.info(`Download started for ${process.item.Name}`);
+ updateProcess(process.id, { state: "downloading" });
+ })
+ .progress((data) => {
+ const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
+ updateProcess(process.id, {
state: "downloading",
progress: percent,
- };
+ });
+ })
+ .done(async () => {
+ removeProcess(process.id);
+ await saveDownloadedItemInfo(process.item);
+ await queryClient.invalidateQueries({
+ queryKey: ["downloadedItems"],
+ });
+ await refetch();
+ completeHandler(process.id);
+ toast.success(`Download completed for ${process.item.Name}`);
+ })
+ .error((error) => {
+ updateProcess(process.id, { state: "error" });
+ toast.error(`Download failed for ${process.item.Name}: ${error}`);
});
- })
- .done(async () => {
- clearProcess();
- await saveDownloadedItemInfo(process.item);
- await queryClient.invalidateQueries({
- queryKey: ["downloadedItems"],
- });
- await refetch();
- completeHandler(process.id);
- toast.success(`Download completed for ${process.item.Name}`);
- })
- .error((error) => {
- updateProcess((prev) => {
- if (!prev) return null;
- return {
- ...prev,
- state: "error",
- };
- });
- toast.error(`Download failed for ${process.item.Name}: ${error}`);
- });
- }, [queryClient, process?.id, settings?.optimizedVersionsServerUrl]);
+ },
+ [queryClient, settings?.optimizedVersionsServerUrl]
+ );
useEffect(() => {
- let intervalId: NodeJS.Timeout | null = null;
-
const checkJobStatusPeriodically = async () => {
- // console.log("checkJobStatusPeriodically ~");
- if (
- !process?.id ||
- !process.state ||
- !process.item.Id ||
- !settings?.optimizedVersionsServerUrl
- )
- return;
- if (process.state === "optimizing") {
- const job = await checkJobStatus(
- process.id,
- settings?.optimizedVersionsServerUrl,
- authHeader
- );
+ if (!settings?.optimizedVersionsServerUrl) return;
- if (!job) {
- clearProcess();
- return;
- }
+ const updatedProcesses = await Promise.all(
+ processes.map(async (process) => {
+ if (!settings.optimizedVersionsServerUrl) return;
+ if (process.state === "queued" || process.state === "optimizing") {
+ const job = await checkJobStatus(
+ process.id,
+ settings.optimizedVersionsServerUrl,
+ authHeader
+ );
- // Update the local process state with the state from the server.
- let newState: ProcessItem["state"] = "optimizing";
- if (job.status === "completed") {
- if (intervalId) clearInterval(intervalId);
- startDownload();
- return;
- } else if (job.status === "failed") {
- newState = "error";
- } else if (job.status === "cancelled") {
- newState = "canceled";
- }
+ if (!job) {
+ return null;
+ }
- updateProcess((prev) => {
- if (!prev) return null;
- return {
- ...prev,
- state: newState,
- progress: job.progress,
- };
- });
- } else if (process.state === "downloading") {
- // Don't do anything, it's downloading locally
- return;
- } else if (["done", "canceled", "error"].includes(process.state)) {
- console.log("Job is done or failed or canceled");
- clearProcess();
- if (intervalId) clearInterval(intervalId);
- }
+ let newState: ProcessItem["state"] = process.state;
+ if (job.status === "queued") {
+ newState = "queued";
+ } else if (job.status === "running") {
+ newState = "optimizing";
+ } else if (job.status === "completed") {
+ startDownload(process);
+ return null;
+ } else if (job.status === "failed") {
+ newState = "error";
+ } else if (job.status === "cancelled") {
+ newState = "canceled";
+ }
+
+ return { ...process, state: newState, progress: job.progress };
+ }
+ return process;
+ })
+ );
+
+ // Filter out null values (completed or cancelled jobs)
+ const filteredProcesses = updatedProcesses.filter(
+ (process) => process !== null
+ ) as ProcessItem[];
+
+ // Update the state with the filtered processes
+ setProcesses(filteredProcesses);
};
- console.log("Starting interval check");
+ const intervalId = setInterval(checkJobStatusPeriodically, 2000);
- // Start checking immediately
- checkJobStatusPeriodically();
-
- // Then check every 2 seconds
- intervalId = setInterval(checkJobStatusPeriodically, 2000);
-
- // Clean up function
- return () => {
- if (intervalId) {
- clearInterval(intervalId);
- }
- };
- }, [process?.id, settings?.optimizedVersionsServerUrl]);
+ return () => clearInterval(intervalId);
+ }, [processes, settings?.optimizedVersionsServerUrl]);
const startBackgroundDownload = useCallback(
async (url: string, item: BaseItemDto) => {
@@ -252,14 +227,14 @@ function useDownloadProvider() {
const { id } = response.data;
- updateProcess({
+ addProcess({
id,
item: item,
progress: 0,
- state: "optimizing",
+ state: "queued",
});
- toast.success(`Optimization started for ${item.Name}`, {
+ toast.success(`Queued ${item.Name} for optimization`, {
action: {
label: "Go to download",
onClick: () => {
@@ -276,22 +251,16 @@ function useDownloadProvider() {
[settings?.optimizedVersionsServerUrl]
);
- /**
- * Deletes all downloaded files and clears the download record.
- */
const deleteAllFiles = async (): Promise => {
try {
- // Get the base directory
const baseDirectory = FileSystem.documentDirectory;
if (!baseDirectory) {
throw new Error("Base directory not found");
}
- // Read the contents of the base directory
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
- // Delete each item in the directory
for (const item of dirContents) {
const itemPath = `${baseDirectory}${item}`;
const itemInfo = await FileSystem.getInfoAsync(itemPath);
@@ -300,12 +269,10 @@ function useDownloadProvider() {
await FileSystem.deleteAsync(itemPath, { idempotent: true });
}
}
- // Clear the downloadedItems in AsyncStorage
await AsyncStorage.removeItem("downloadedItems");
- await AsyncStorage.removeItem("runningProcess");
- clearProcess();
+ await AsyncStorage.removeItem("runningProcesses");
+ clearProcesses();
- // Invalidate the query to refresh the UI
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
console.log(
@@ -316,10 +283,6 @@ function useDownloadProvider() {
}
};
- /**
- * Deletes a specific file and updates the download record.
- * @param id - The ID of the file to delete.
- */
const deleteFile = async (id: string): Promise => {
if (!id) {
console.error("Invalid file ID");
@@ -327,17 +290,14 @@ function useDownloadProvider() {
}
try {
- // Get the directory path
const directory = FileSystem.documentDirectory;
if (!directory) {
console.error("Document directory not found");
return;
}
- // Read the contents of the directory
const dirContents = await FileSystem.readDirectoryAsync(directory);
- // Find and delete the file with the matching ID (without extension)
for (const item of dirContents) {
const itemNameWithoutExtension = item.split(".")[0];
if (itemNameWithoutExtension === id) {
@@ -348,7 +308,6 @@ function useDownloadProvider() {
}
}
- // Remove the item from AsyncStorage
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
if (downloadedItems) {
let items = JSON.parse(downloadedItems);
@@ -356,7 +315,6 @@ function useDownloadProvider() {
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
}
- // Invalidate the query to refresh the UI
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
console.log(
@@ -370,10 +328,6 @@ function useDownloadProvider() {
}
};
- /**
- * Retrieves the list of downloaded files from AsyncStorage.
- * @returns An array of BaseItemDto objects representing downloaded files.
- */
async function getAllDownloadedItems(): Promise {
try {
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
@@ -411,19 +365,20 @@ function useDownloadProvider() {
}
return {
- process,
+ processes,
updateProcess,
startBackgroundDownload,
- clearProcess,
- readProcess,
+ clearProcesses,
+ readProcesses,
downloadedFiles,
deleteAllFiles,
deleteFile,
saveDownloadedItemInfo,
+ addProcess,
+ removeProcess,
};
}
-// Create the provider component
export function DownloadProvider({ children }: { children: React.ReactNode }) {
const downloadProviderValue = useDownloadProvider();
const queryClient = new QueryClient();
@@ -435,7 +390,6 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
);
}
-// Create a custom hook to use the download context
export function useDownload() {
const context = useContext(DownloadContext);
if (context === null) {
@@ -450,7 +404,7 @@ const checkJobStatus = async (
authHeader?: string | null
): Promise<{
progress: number;
- status: "running" | "completed" | "failed" | "cancelled";
+ status: "queued" | "running" | "completed" | "failed" | "cancelled";
}> => {
const statusResponse = await axios.get(`${baseUrl}job-status/${id}`, {
headers: {