diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 06669d56..8e63c332 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -2,10 +2,15 @@ import { Text } from "@/components/common/Text"; import ProgressCircle from "@/components/ProgressCircle"; import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types"; import { useNativeDownloads } from "@/providers/NativeDownloadProvider"; +import { storage } from "@/utils/mmkv"; +import { formatTimeString, ticksToSeconds } from "@/utils/time"; +import { useActionSheet } from "@expo/react-native-action-sheet"; import { Ionicons } from "@expo/vector-icons"; import { useQueryClient } from "@tanstack/react-query"; +import * as FileSystem from "expo-file-system"; +import { Image } from "expo-image"; import { useRouter } from "expo-router"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { ActivityIndicator, RefreshControl, @@ -14,20 +19,6 @@ import { View, } from "react-native"; -const formatETA = (seconds: number): string => { - const hrs = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - const parts: string[] = []; - - if (hrs > 0) parts.push(`${hrs}h`); - if (mins > 0) parts.push(`${mins}m`); - if (secs > 0 || parts.length === 0) parts.push(`${secs}s`); - - return parts.join(" "); -}; - const getETA = (download: DownloadInfo): string | null => { if ( !download.startTime || @@ -37,34 +28,57 @@ const getETA = (download: DownloadInfo): string | null => { console.log(download); return null; } - - const elapsed = Date.now() / 1000 - download.startTime; // seconds - + const elapsed = Date.now() / 1000 - download.startTime; if (elapsed <= 0 || download.secondsDownloaded <= 0) return null; - - const speed = download.secondsDownloaded / elapsed; // downloaded seconds per second + const speed = download.secondsDownloaded / elapsed; const remainingBytes = download.secondsTotal - download.secondsDownloaded; - if (speed <= 0) return null; - const secondsLeft = remainingBytes / speed; - - return formatETA(secondsLeft); -}; - -const formatBytes = (i: number) => { - const units = ["B", "KB", "MB", "GB", "TB"]; - let l = 0; - let n = parseInt(i.toString(), 10) || 0; - while (n >= 1024 && ++l) { - n = n / 1024; - } - return n.toFixed(n < 10 && l > 0 ? 1 : 0) + " " + units[l]; + return formatTimeString(secondsLeft, "s"); }; export default function Index() { - const { downloadedFiles, activeDownloads } = useNativeDownloads(); + const { showActionSheetWithOptions } = useActionSheet(); + const { + downloadedFiles, + activeDownloads, + cancelDownload, + refetchDownloadedFiles, + } = useNativeDownloads(); const router = useRouter(); + const queryClient = useQueryClient(); + + const handleItemPress = (item: any) => { + showActionSheetWithOptions( + { + options: ["Play", "Delete", "Cancel"], + destructiveButtonIndex: 1, + cancelButtonIndex: 2, + }, + async (selectedIndex) => { + if (selectedIndex === 0) { + goToVideo(item); + } else if (selectedIndex === 1) { + await deleteFile(item.id); + } + } + ); + }; + + const handleActiveItemPress = (id: string) => { + showActionSheetWithOptions( + { + options: ["Cancel Download", "Cancel"], + destructiveButtonIndex: 0, + cancelButtonIndex: 1, + }, + async (selectedIndex) => { + if (selectedIndex === 0) { + await cancelDownload(id); + } + } + ); + }; const goToVideo = (item: any) => { // @ts-expect-error @@ -80,7 +94,15 @@ export default function Index() { [downloadedFiles] ); - const queryClient = useQueryClient(); + const base64Image = useCallback((id: string) => { + return storage.getString(id); + }, []); + + const deleteFile = async (id: string) => { + const downloadsDir = FileSystem.documentDirectory + "downloads/"; + await FileSystem.deleteAsync(downloadsDir + id + ".json"); + await queryClient.invalidateQueries({ queryKey: ["downloadedFiles"] }); + }; return ( 0 } - onRefresh={async () => { - await queryClient.invalidateQueries({ - queryKey: ["downloadedFiles"], - }); + onRefresh={() => { + refetchDownloadedFiles(); }} /> } - className="p-4 space-y-2" + className="flex-1 " > - {activeDownloads.length ? ( - - - ACTIVE DOWNLOADS - - {activeDownloads.map((i) => { - const progress = - i.secondsTotal && i.secondsDownloaded - ? i.secondsDownloaded / i.secondsTotal - : 0; - const eta = getETA(i); - const item = i.metadata?.item; - return ( - - - {item.Type === "Episode" ? ( - {item?.SeriesName} - ) : null} - {item?.Name} - - {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} - - - - {eta ? `${eta} remaining` : "Calculating time..."} - - - + + {!movies.length && !episodes.length && !activeDownloads.length ? ( + + + No downloaded items + + + ) : null} - {i.state === "PENDING" ? ( - - ) : ( - - )} + {activeDownloads.length ? ( + + + ACTIVE DOWNLOADS + + + {activeDownloads.map((i) => { + const progress = + i.secondsTotal && i.secondsDownloaded + ? i.secondsDownloaded / i.secondsTotal + : 0; + const eta = getETA(i); + const item = i.metadata?.item; + return ( + { + if (!i.metadata.item.Id) throw new Error("No item id"); + handleActiveItemPress(i.metadata.item.Id); + }} + key={i.id} + className="flex flex-row items-center p-2 pr-4 rounded-xl bg-neutral-900 space-x-4" + > + {i.metadata.item.Id && ( + + + + )} + + {item.Type === "Episode" ? ( + <> + {item?.SeriesName} + + ) : ( + <> + + {item?.ProductionYear} + + + )} + {item?.Name} + + {i.metadata.item.Type === "Episode" && ( + + {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "} + -{" "} + + )} + {item?.RunTimeTicks ? ( + + {formatTimeString( + ticksToSeconds(item?.RunTimeTicks), + "s" + )} + + ) : null} + + + + {eta ? `~${eta} remaining` : "Calculating time..."} + + + + + + {i.state === "PENDING" ? ( + + ) : ( + + + + {(progress * 100).toFixed(0)}% + + + )} + + + ); + })} + + + ) : null} + + + {movies && movies.length ? ( + + MOVIES + + {movies.map((i) => ( + handleItemPress(i)} + className="flex flex-row items-center p-2 pr-4 rounded-xl bg-neutral-900 space-x-4" + > + {i.metadata.item.Id && ( + + + + )} + + + {i.metadata.item?.ProductionYear} + + + {i.metadata.item?.Name} + + {i.metadata.item?.RunTimeTicks ? ( + + {formatTimeString( + ticksToSeconds(i.metadata.item?.RunTimeTicks), + "s" + )} + + ) : null} + + + + ))} - ); - })} + + ) : null} + {episodes && episodes.length ? ( + + + EPISODES + + + {episodes.map((i) => ( + handleItemPress(i)} + className="bg-neutral-900 p-2 pr-4 rounded-xl flex flex-row items-center space-x-4" + > + {i.metadata.item.Id && ( + + + + )} + + {i.metadata.item.Type === "Episode" ? ( + + {i.metadata.item?.SeriesName} + + ) : null} + + {i.metadata.item?.Name} + + + {`S${i.metadata.item.ParentIndexNumber?.toString()}:E${i.metadata.item.IndexNumber?.toString()}`} + + + + + ))} + + + ) : null} - ) : null} - - - {movies && movies.length ? ( - - MOVIES - - {movies.map((i) => ( - goToVideo(i)} - className="bg-neutral-900 p-4 rounded-xl flex flex-row items-center justify-between" - > - - {i.metadata.item.Type === "Episode" ? ( - - {i.metadata.item?.SeriesName} - - ) : null} - - {i.metadata.item?.Name} - - - {`S${i.metadata.item.ParentIndexNumber?.toString()}:E${i.metadata.item.IndexNumber?.toString()}`} - - - - - ))} - - - ) : null} - {episodes && episodes.length ? ( - - EPISODES - - {episodes.map((i) => ( - goToVideo(i)} - className="bg-neutral-900 p-4 rounded-xl flex flex-row items-center justify-between" - > - - {i.metadata.item.Type === "Episode" ? ( - - {i.metadata.item?.SeriesName} - - ) : null} - - {i.metadata.item?.Name} - - - {`S${i.metadata.item.ParentIndexNumber?.toString()}:E${i.metadata.item.IndexNumber?.toString()}`} - - - - - ))} - - - ) : null} ); diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 04b82a28..a4bbfc92 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -130,6 +130,7 @@ export default function page() { let m3u8Url = ""; const path = `${FileSystem.documentDirectory}/downloads/${item?.Id}/Data`; const files = await FileSystem.readDirectoryAsync(path); + for (const file of files) { if (file.endsWith(".m3u8")) { console.log(file); @@ -138,10 +139,11 @@ export default function page() { } } - console.log({ - mediaSource: data.mediaSource, + if (!m3u8Url) throw new Error("No m3u8 file found"); + + console.log("stream ~", { + mediaSource: data.mediaSource.Id, url: m3u8Url, - sessionId: undefined, }); if (item) diff --git a/app/_layout.tsx b/app/_layout.tsx index 7f12fb8d..047f158c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -29,9 +29,6 @@ import { SystemBars } from "react-native-edge-to-edge"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; import { Toaster } from "sonner-native"; -const BackGroundDownloader = !Platform.isTV - ? require("@kesha-antonov/react-native-background-downloader") - : null; const Notifications = !Platform.isTV ? require("expo-notifications") : null; if (!Platform.isTV) { @@ -171,26 +168,6 @@ function Layout() { ); } }, [settings]); - - useEffect(() => { - const subscription = AppState.addEventListener( - "change", - (nextAppState) => { - if ( - appState.current.match(/inactive|background/) && - nextAppState === "active" - ) { - BackGroundDownloader.checkForExistingDownloads(); - } - } - ); - - BackGroundDownloader.checkForExistingDownloads(); - - return () => { - subscription.remove(); - }; - }, []); } const [loaded] = useFonts({ diff --git a/components/downloads/NativeDownloadButton.tsx b/components/downloads/NativeDownloadButton.tsx index 94a184a6..b86134d7 100644 --- a/components/downloads/NativeDownloadButton.tsx +++ b/components/downloads/NativeDownloadButton.tsx @@ -116,7 +116,18 @@ export const NativeDownloadButton: React.FC = ({ selectedSubtitleStream, selectedMediaSource, }); - toast.success("Download started"); + toast.success( + t("home.downloads.toasts.download_started_for", { item: item.Name }), + { + action: { + label: "Go to download", + onClick: () => { + router.push("/downloads"); + toast.dismiss(); + }, + }, + } + ); } catch (error) { console.error("Download error:", error); toast.error("Failed to start download"); diff --git a/modules/hls-downloader/index.ts b/modules/hls-downloader/index.ts index 25740d74..3237935c 100644 --- a/modules/hls-downloader/index.ts +++ b/modules/hls-downloader/index.ts @@ -33,6 +33,11 @@ async function checkForExistingDownloads(): Promise { return HlsDownloaderModule.checkForExistingDownloads(); } +/** + * Cancels an ongoing download. + * @param id - The unique identifier for the download. + * @returns void + */ async function cancelDownload(id: string): Promise { return HlsDownloaderModule.cancelDownload(id); } diff --git a/modules/hls-downloader/ios/HlsDownloaderModule.swift b/modules/hls-downloader/ios/HlsDownloaderModule.swift index 8480357e..c49af455 100644 --- a/modules/hls-downloader/ios/HlsDownloaderModule.swift +++ b/modules/hls-downloader/ios/HlsDownloaderModule.swift @@ -8,6 +8,13 @@ public class HlsDownloaderModule: Module { startTime: Double )] = [:] + struct DownloadRequest { + let providedId: String + let url: String + let metadata: [String: Any]? + } + var pendingDownloads: [DownloadRequest] = [] + public func definition() -> ModuleDefinition { Name("HlsDownloader") @@ -16,9 +23,51 @@ public class HlsDownloaderModule: Module { Function("downloadHLSAsset") { (providedId: String, url: String, metadata: [String: Any]?) -> Void in let startTime = Date().timeIntervalSince1970 - print( - "Starting download - ID: \(providedId), URL: \(url), Metadata: \(String(describing: metadata)), StartTime: \(startTime)" - ) + + // Enforce max 3 concurrent downloads. + if self.activeDownloads.count >= 3 { + self.pendingDownloads.append( + DownloadRequest(providedId: providedId, url: url, metadata: metadata)) + self.sendEvent( + "onProgress", + [ + "id": providedId, + "progress": 0.0, + "state": "QUEUED", + "metadata": metadata ?? [:], + "startTime": startTime, + ]) + return + } + + // First check if the asset already exists + let fm = FileManager.default + let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0] + let downloadsDir = docs.appendingPathComponent("downloads", isDirectory: true) + let potentialExistingLocation = downloadsDir.appendingPathComponent( + providedId, isDirectory: true) + + if fm.fileExists(atPath: potentialExistingLocation.path) { + // Check if the download is complete by looking for the master playlist + if let files = try? fm.contentsOfDirectory(atPath: potentialExistingLocation.path), + files.contains(where: { $0.hasSuffix(".m3u8") }) + { + // Asset exists and appears complete, send completion event + self.sendEvent( + "onComplete", + [ + "id": providedId, + "location": potentialExistingLocation.absoluteString, + "state": "DONE", + "metadata": metadata ?? [:], + "startTime": startTime, + ]) + return + } else { + // Asset exists but appears incomplete, clean it up + try? fm.removeItem(at: potentialExistingLocation) + } + } guard let assetURL = URL(string: url) else { self.sendEvent( @@ -33,7 +82,7 @@ public class HlsDownloaderModule: Module { return } - // Add asset options to allow cellular downloads and specify allowed media types + // Rest of the download logic remains the same let asset = AVURLAsset( url: assetURL, options: [ @@ -42,7 +91,6 @@ public class HlsDownloaderModule: Module { "AVURLAssetAllowsCellularAccessKey": true, ]) - // Validate the asset before proceeding asset.loadValuesAsynchronously(forKeys: ["playable", "duration"]) { var error: NSError? let status = asset.statusOfValue(forKey: "playable", error: &error) @@ -63,7 +111,7 @@ public class HlsDownloaderModule: Module { } let configuration = URLSessionConfiguration.background( - withIdentifier: "com.streamyfin.hlsdownload") + withIdentifier: "com.streamyfin.hlsdownload.\(providedId)") // Add unique identifier configuration.allowsCellularAccess = true configuration.sessionSendsLaunchEvents = true configuration.isDiscretionary = false @@ -103,7 +151,9 @@ public class HlsDownloaderModule: Module { } delegate.taskIdentifier = task.taskIdentifier + self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime) + self.sendEvent( "onProgress", [ @@ -115,7 +165,6 @@ public class HlsDownloaderModule: Module { ]) task.resume() - print("Download task started with identifier: \(task.taskIdentifier)") } } } @@ -153,8 +202,6 @@ public class HlsDownloaderModule: Module { return } let (task, delegate, metadata, startTime) = entry.value - task.cancel() - self.activeDownloads.removeValue(forKey: task.taskIdentifier) self.sendEvent( "onError", [ @@ -164,6 +211,9 @@ public class HlsDownloaderModule: Module { "metadata": metadata, "startTime": startTime, ]) + task.cancel() + self.activeDownloads.removeValue(forKey: task.taskIdentifier) + self.startNextDownloadIfNeeded() print("Download cancelled for identifier: \(providedId)") } @@ -183,16 +233,26 @@ public class HlsDownloaderModule: Module { try fm.createDirectory(at: downloadsDir, withIntermediateDirectories: true) } let newLocation = downloadsDir.appendingPathComponent(folderName, isDirectory: true) + + // New atomic move implementation + let tempLocation = downloadsDir.appendingPathComponent("\(folderName)_temp", isDirectory: true) + + // Clean up any existing temp folder + if fm.fileExists(atPath: tempLocation.path) { + try fm.removeItem(at: tempLocation) + } + + // Move to temp location first + try fm.moveItem(at: originalLocation, to: tempLocation) + + // If target exists, remove it if fm.fileExists(atPath: newLocation.path) { try fm.removeItem(at: newLocation) } - // If the original file exists, move it. Otherwise, if newLocation already exists, assume it was moved. - if fm.fileExists(atPath: originalLocation.path) { - try fm.moveItem(at: originalLocation, to: newLocation) - } else if !fm.fileExists(atPath: newLocation.path) { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil) - } + // Final move from temp to target + try fm.moveItem(at: tempLocation, to: newLocation) + return newLocation } @@ -215,6 +275,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { var downloadedSeconds: Double = 0 var totalSeconds: Double = 0 var startTime: Double = 0 + private var wasCancelled = false init(module: HlsDownloaderModule) { self.module = module @@ -255,6 +316,10 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { _ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL ) { + if wasCancelled { + return + } + let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:] let startTime = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.startTime ?? 0 let folderName = providedId @@ -330,6 +395,12 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let error = error { + if (error as NSError).code == NSURLErrorCancelled { + wasCancelled = true + module?.removeDownload(with: taskIdentifier) + return + } + let metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:] let startTime = module?.activeDownloads[task.taskIdentifier]?.startTime ?? 0 module?.sendEvent( diff --git a/modules/hls-downloader/src/HlsDownloader.types.ts b/modules/hls-downloader/src/HlsDownloader.types.ts index 229c10d7..98f268f1 100644 --- a/modules/hls-downloader/src/HlsDownloader.types.ts +++ b/modules/hls-downloader/src/HlsDownloader.types.ts @@ -4,11 +4,13 @@ import { } from "@jellyfin/sdk/lib/generated-client"; export type DownloadState = + | "QUEUED" | "PENDING" | "DOWNLOADING" | "PAUSED" | "DONE" | "FAILED" + | "CANCELLED" | "STOPPED"; export interface DownloadMetadata { diff --git a/providers/NativeDownloadProvider.tsx b/providers/NativeDownloadProvider.tsx index b566f1ac..626fe6fb 100644 --- a/providers/NativeDownloadProvider.tsx +++ b/providers/NativeDownloadProvider.tsx @@ -5,6 +5,7 @@ import { addProgressListener, checkForExistingDownloads, downloadHLSAsset, + cancelDownload, } from "@/modules/hls-downloader"; import { DownloadInfo, @@ -45,8 +46,10 @@ type DownloadContextType = { }: DownloadOptionsData ) => Promise; getDownloadedItem: (id: string) => Promise; + cancelDownload: (id: string) => Promise; activeDownloads: DownloadInfo[]; downloadedFiles: DownloadedFileInfo[]; + refetchDownloadedFiles: () => void; }; const DownloadContext = createContext( @@ -82,22 +85,26 @@ export type DownloadedFileInfo = { }; const getDownloadedFiles = async (): Promise => { - const downloadsDir = FileSystem.documentDirectory + "downloads/"; - const dirInfo = await FileSystem.getInfoAsync(downloadsDir); - if (!dirInfo.exists) return []; - const files = await FileSystem.readDirectoryAsync(downloadsDir); const downloaded: DownloadedFileInfo[] = []; - for (const file of files) { + const downloadsDir = FileSystem.documentDirectory + "downloads/"; + const dirInfo = await FileSystem.getInfoAsync(downloadsDir); + + if (!dirInfo.exists) return []; + + const files = await FileSystem.readDirectoryAsync(downloadsDir); + + for (let file of files) { const fileInfo = await FileSystem.getInfoAsync(downloadsDir + file); if (fileInfo.isDirectory) continue; + if (!file.endsWith(".json")) continue; - const doneFile = await isFileMarkedAsDone(file.replace(".json", "")); - if (!doneFile) continue; + const fileContent = await FileSystem.readAsStringAsync(downloadsDir + file); - const fileContent = await FileSystem.readAsStringAsync( - downloadsDir + file.replace("-done", "") - ); + // Check that fileContent is actually DownloadMetadata + if (!fileContent) continue; + if (!fileContent.includes("mediaSource")) continue; + if (!fileContent.includes("item")) continue; downloaded.push({ id: file.replace(".json", ""), @@ -112,8 +119,6 @@ const getDownloadedFile = async (id: string) => { const downloadsDir = FileSystem.documentDirectory + "downloads/"; const fileInfo = await FileSystem.getInfoAsync(downloadsDir + id + ".json"); if (!fileInfo.exists) return null; - const doneFile = await isFileMarkedAsDone(id); - if (!doneFile) return null; const fileContent = await FileSystem.readAsStringAsync( downloadsDir + id + ".json" ); @@ -130,7 +135,7 @@ export const NativeDownloadProvider: React.FC<{ const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); - const { data: downloadedFiles } = useQuery({ + const { data: downloadedFiles, refetch: refetchDownloadedFiles } = useQuery({ queryKey: ["downloadedFiles"], queryFn: getDownloadedFiles, refetchOnWindowFocus: true, @@ -140,9 +145,7 @@ export const NativeDownloadProvider: React.FC<{ }); useEffect(() => { - // Initialize downloads from both HLS and regular downloads const initializeDownloads = async () => { - // Check HLS downloads const hlsDownloads = await checkForExistingDownloads(); const hlsDownloadStates = hlsDownloads.reduce( (acc, download) => ({ @@ -204,7 +207,7 @@ export const NativeDownloadProvider: React.FC<{ await queryClient.invalidateQueries({ queryKey: ["downloadedFiles"] }); - toast.success("Download complete ✅"); + if (payload.state === "DONE") toast.success("Download complete ✅"); } catch (error) { console.error("Failed to download file:", error); toast.error("Failed to download ❌"); @@ -217,7 +220,12 @@ export const NativeDownloadProvider: React.FC<{ delete newDownloads[error.id]; return newDownloads; }); - toast.error("Failed to download ❌"); + + if (error.state === "CANCELLED") toast.info("Download cancelled 🟡"); + else { + toast.error("Download failed ❌"); + console.error("Download error:", error); + } }); return () => { @@ -227,43 +235,6 @@ export const NativeDownloadProvider: React.FC<{ }; }, []); - useEffect(() => { - // Go through all the files in the folder downloads, check for the file id.json and id-done.json, if the id.json exists but id-done.json does not exist, then the download is still in done but not parsed. Parse it. - const checkForUnparsedDownloads = async () => { - const downloadsFolder = await FileSystem.getInfoAsync( - FileSystem.documentDirectory + "downloads" - ); - if (!downloadsFolder.exists) return; - const files = await FileSystem.readDirectoryAsync( - FileSystem.documentDirectory + "downloads" - ); - for (const file of files) { - if (file.endsWith(".json")) { - const id = file.replace(".json", ""); - const doneFile = await FileSystem.getInfoAsync( - FileSystem.documentDirectory + "downloads/" + id + "-done" - ); - if (!doneFile.exists) { - console.log("Found unparsed download:", id); - - const p = async () => { - await rewriteM3U8Files( - FileSystem.documentDirectory + "downloads/" + id - ); - await markFileAsDone(id); - }; - toast.promise(p(), { - error: () => "Failed to download ❌", - loading: "Finishing up download...", - success: () => "Download complete ✅", - }); - } - } - } - }; - // checkForUnparsedDownloads(); - }, []); - const startDownload = async ( item: BaseItemDto, url: string, @@ -315,6 +286,8 @@ export const NativeDownloadProvider: React.FC<{ downloadedFiles: downloadedFiles ?? [], getDownloadedItem: getDownloadedFile, activeDownloads: Object.values(downloads), + cancelDownload: cancelDownload, + refetchDownloadedFiles, }} > {children}