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: {