From ca726e0ca5d330994ac1e863226a88e02f4f8389 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 15 Feb 2025 22:35:10 +0100 Subject: [PATCH] wip --- app/_layout.tsx | 91 ++++--- components/NativeDownloadButton.tsx | 230 +++------------- modules/hls-downloader/index.ts | 11 +- .../ios/HlsDownloaderModule.swift | 157 ++++++----- .../hls-downloader/src/HlsDownloader.types.ts | 39 ++- providers/NativeDownloadProvider.tsx | 255 ++++++++++++++++++ 6 files changed, 472 insertions(+), 311 deletions(-) create mode 100644 providers/NativeDownloadProvider.tsx diff --git a/app/_layout.tsx b/app/_layout.tsx index 55652930..cfe81c59 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -47,6 +47,7 @@ import { SystemBars } from "react-native-edge-to-edge"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; import { Toaster } from "sonner-native"; +import { NativeDownloadProvider } from "@/providers/NativeDownloadProvider"; if (!Platform.isTV) { Notifications.setNotificationHandler({ @@ -321,52 +322,54 @@ function Layout() { - - + + + diff --git a/components/NativeDownloadButton.tsx b/components/NativeDownloadButton.tsx index 745b46d6..dea18791 100644 --- a/components/NativeDownloadButton.tsx +++ b/components/NativeDownloadButton.tsx @@ -14,20 +14,11 @@ import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import RNBackgroundDownloader, { - DownloadTaskState, -} from "@kesha-antonov/react-native-background-downloader"; import { useFocusEffect } from "expo-router"; import { t } from "i18next"; import { useAtom } from "jotai"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { View, ViewProps } from "react-native"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, View, ViewProps } from "react-native"; import { toast } from "sonner-native"; import { AudioTrackSelector } from "./AudioTrackSelector"; import { Bitrate, BitrateSelector } from "./BitrateSelector"; @@ -36,20 +27,8 @@ import { Text } from "./common/Text"; import { MediaSourceSelector } from "./MediaSourceSelector"; import { RoundButton } from "./RoundButton"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; - -import * as FileSystem from "expo-file-system"; import ProgressCircle from "./ProgressCircle"; - -import { - downloadHLSAsset, - useDownloadProgress, - useDownloadError, - useDownloadComplete, - addCompleteListener, - addErrorListener, - addProgressListener, - checkForExistingDownloads, -} from "@/modules/hls-downloader"; +import { useNativeDownloads } from "@/providers/NativeDownloadProvider"; interface NativeDownloadButton extends ViewProps { item: BaseItemDto; @@ -58,13 +37,6 @@ interface NativeDownloadButton extends ViewProps { size?: "default" | "large"; } -type DownloadState = { - id: string; - progress: number; - state: DownloadTaskState; - metadata?: {}; -}; - export const NativeDownloadButton: React.FC = ({ item, title = "Download", @@ -75,10 +47,7 @@ export const NativeDownloadButton: React.FC = ({ const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [settings] = useSettings(); - - const [activeDownload, setActiveDownload] = useState< - DownloadState | undefined - >(undefined); + const { downloads, startDownload } = useNativeDownloads(); const [selectedMediaSource, setSelectedMediaSource] = useState< MediaSourceInfo | undefined | null @@ -118,69 +87,27 @@ export const NativeDownloadButton: React.FC = ({ if (userCanDownload === true) { closeModal(); - console.log({ - selectedAudioStream, - selectedMediaSource, - selectedSubtitleStream, - maxBitrate, - item, - }); + try { + const res = await getStreamUrl({ + api, + item, + startTimeTicks: 0, + userId: user?.Id, + audioStreamIndex: selectedAudioStream, + maxStreamingBitrate: maxBitrate.value, + mediaSourceId: selectedMediaSource?.Id, + subtitleStreamIndex: selectedSubtitleStream, + deviceProfile: download, + }); - const res = await getStreamUrl({ - api, - item, - startTimeTicks: 0, - userId: user?.Id, - audioStreamIndex: selectedAudioStream, - maxStreamingBitrate: maxBitrate.value, - mediaSourceId: selectedMediaSource?.Id, - subtitleStreamIndex: selectedSubtitleStream, - deviceProfile: download, - }); - - console.log("acceptDownloadOptions ~", res); - - if (!res?.url) throw new Error("No url found"); - - if (res.url.includes("master.m3u8")) { - // TODO: Download with custom native module - console.log("TODO: Download with custom native module"); + if (!res?.url) throw new Error("No url found"); if (!item.Id || !item.Name) throw new Error("No item id found"); - downloadHLSAsset(item.Id, res.url, item.Name); - } else { - // Download with reac-native-background-downloader - const destination = `${FileSystem.documentDirectory}${item.Name}.mkv`; - const jobId = item.Id!; - try { - RNBackgroundDownloader.download({ - id: jobId, - url: res.url, - destination, - }) - .begin(({ expectedBytes, headers }) => { - console.log(`Starting download of ${expectedBytes} bytes`); - toast.success("Download started"); - setActiveDownload({ - id: jobId, - progress: 0, - state: "DOWNLOADING", - }); - }) - .progress(({ bytesDownloaded, bytesTotal }) => - console.log(`Downloaded: ${bytesDownloaded} of ${bytesTotal}`) - ) - .done(({ bytesDownloaded, bytesTotal }) => { - console.log("Download completed:", bytesDownloaded, bytesTotal); - - RNBackgroundDownloader.completeHandler(jobId); - }) - .error(({ error, errorCode }) => - console.error("Download error:", error) - ); - } catch (error) { - console.log("error ~", error); - } + await startDownload(item, res.url); + toast.success("Download started"); + } catch (error) { + console.error("Download error:", error); + toast.error("Failed to start download"); } } else { toast.error( @@ -195,87 +122,11 @@ export const NativeDownloadButton: React.FC = ({ selectedMediaSource, selectedAudioStream, selectedSubtitleStream, + item, + user, + api, ]); - useEffect(() => { - const progressListener = addProgressListener((_item) => { - console.log("progress ~", item); - if (item.Id !== _item.id) return; - setActiveDownload((prev) => { - if (!prev) return undefined; - return { - ...prev, - progress: _item.progress, - state: _item.state, - }; - }); - }); - - checkForExistingDownloads().then((downloads) => { - console.log( - "AVAssetDownloadURLSession ~ checkForExistingDownloads ~", - downloads - ); - - const firstDownload = downloads?.[0]; - - if (!firstDownload) return; - if (firstDownload.id !== item.Id) return; - - setActiveDownload({ - id: firstDownload?.id, - progress: firstDownload?.progress, - state: firstDownload?.state, - }); - }); - - return () => { - progressListener.remove(); - }; - }, []); - - // useEffect(() => { - // console.log(progress); - - // // setActiveDownload({ - // // id: activeDownload?.id!, - // // progress, - // // state: "DOWNLOADING", - // // }); - // }, [progress]); - - useEffect(() => { - RNBackgroundDownloader.checkForExistingDownloads().then((downloads) => { - console.log( - "RNBackgroundDownloader ~ checkForExistingDownloads ~", - downloads - ); - const e = downloads?.[0]; - setActiveDownload({ - id: e?.id, - progress: e?.bytesDownloaded / e?.bytesTotal, - state: e?.state, - }); - - e.progress(({ bytesDownloaded, bytesTotal }) => { - console.log(`Downloaded: ${bytesDownloaded} of ${bytesTotal}`); - setActiveDownload({ - id: e?.id, - progress: bytesDownloaded / bytesTotal, - state: e?.state, - }); - }); - e.done(({ bytesDownloaded, bytesTotal }) => { - console.log("Download completed:", bytesDownloaded, bytesTotal); - setActiveDownload(undefined); - }); - e.error(({ error, errorCode }) => { - console.error("Download error:", error); - setActiveDownload(undefined); - }); - }); - }, []); - useFocusEffect( useCallback(() => { if (!settings) return; @@ -300,25 +151,30 @@ export const NativeDownloadButton: React.FC = ({ [] ); - const onButtonPress = () => { - handlePresentModalPress(); - }; + const activeDownload = item.Id ? downloads[item.Id] : undefined; return ( - {activeDownload && activeDownload?.progress > 0 ? ( - + {activeDownload ? ( + <> + {activeDownload.state === "PENDING" && ( + + )} + {activeDownload.state === "DOWNLOADING" && ( + + )} + ) : ( )} diff --git a/modules/hls-downloader/index.ts b/modules/hls-downloader/index.ts index 2c75179a..7f3bd47b 100644 --- a/modules/hls-downloader/index.ts +++ b/modules/hls-downloader/index.ts @@ -3,6 +3,7 @@ import { type EventSubscription } from "expo-modules-core"; import { useEffect, useState } from "react"; import type { + DownloadMetadata, OnCompleteEventPayload, OnErrorEventPayload, OnProgressEventPayload, @@ -14,9 +15,15 @@ import HlsDownloaderModule from "./src/HlsDownloaderModule"; * @param id - A unique identifier for the download. * @param url - The HLS stream URL. * @param assetTitle - A title for the asset. + * @param destination - The destination path for the downloaded asset. */ -function downloadHLSAsset(id: string, url: string, assetTitle: string): void { - HlsDownloaderModule.downloadHLSAsset(id, url, assetTitle); +function downloadHLSAsset( + id: string, + url: string, + assetTitle: string, + metadata: DownloadMetadata +): void { + HlsDownloaderModule.downloadHLSAsset(id, url, assetTitle, metadata); } /** diff --git a/modules/hls-downloader/ios/HlsDownloaderModule.swift b/modules/hls-downloader/ios/HlsDownloaderModule.swift index dfd151cb..9cb28bf1 100644 --- a/modules/hls-downloader/ios/HlsDownloaderModule.swift +++ b/modules/hls-downloader/ios/HlsDownloaderModule.swift @@ -1,63 +1,72 @@ -// ios/HlsDownloaderModule.swift import ExpoModulesCore import AVFoundation public class HlsDownloaderModule: Module { - var activeDownloads: [Int: (task: AVAssetDownloadTask, delegate: HLSDownloadDelegate)] = [:] + var activeDownloads: [Int: (task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any])] = [:] public func definition() -> ModuleDefinition { Name("HlsDownloader") - + Events("onProgress", "onError", "onComplete") - - Function("downloadHLSAsset") { (providedId: String, url: String, assetTitle: String) -> Void in - print("Starting download - ID: \(providedId), URL: \(url), Title: \(assetTitle)") + + Function("downloadHLSAsset") { (providedId: String, url: String, assetTitle: String, metadata: [String: Any]?) -> Void in + print("Starting download - ID: \(providedId), URL: \(url), Title: \(assetTitle), Metadata: \(String(describing: metadata))") guard let assetURL = URL(string: url) else { - self.sendEvent("onError", ["id": providedId, "error": "Invalid URL", "state": "FAILED"]) + self.sendEvent("onError", [ + "id": providedId, + "error": "Invalid URL", + "state": "FAILED", + "metadata": metadata ?? [:] + ]) return - } - + } + let asset = AVURLAsset(url: assetURL) let configuration = URLSessionConfiguration.background(withIdentifier: "com.example.hlsdownload") let delegate = HLSDownloadDelegate(module: self) delegate.providedId = providedId - let downloadSession = AVAssetDownloadURLSession( configuration: configuration, assetDownloadDelegate: delegate, delegateQueue: OperationQueue.main ) - + guard let task = downloadSession.makeAssetDownloadTask( asset: asset, assetTitle: assetTitle, assetArtworkData: nil, options: nil ) else { - self.sendEvent("onError", ["id": providedId, "error": "Failed to create download task", "state": "FAILED"]) + self.sendEvent("onError", [ + "id": providedId, + "error": "Failed to create download task", + "state": "FAILED", + "metadata": metadata ?? [:] + ]) return - } - + } + delegate.taskIdentifier = task.taskIdentifier - self.activeDownloads[task.taskIdentifier] = (task, delegate) - + self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:]) self.sendEvent("onProgress", [ - "id": providedId, + "id": providedId, "progress": 0.0, - "state": "PENDING" - ]) - + "state": "PENDING", + "metadata": metadata ?? [:] + ]) + task.resume() print("Download task started with identifier: \(task.taskIdentifier)") } - + Function("checkForExistingDownloads") { () -> [[String: Any]] in var downloads: [[String: Any]] = [] for (id, pair) in self.activeDownloads { let task = pair.task let delegate = pair.delegate + let metadata = pair.metadata let downloaded = delegate.downloadedSeconds let total = delegate.totalSeconds let progress = total > 0 ? downloaded / total : 0 @@ -66,20 +75,21 @@ public class HlsDownloaderModule: Module { "progress": progress, "bytesDownloaded": downloaded, "bytesTotal": total, - "state": self.mappedState(for: task) - ]) - } + "state": self.mappedState(for: task), + "metadata": metadata + ]) + } return downloads } - + OnStartObserving { } OnStopObserving { } - } - +} + func removeDownload(with id: Int) { activeDownloads.removeValue(forKey: id) } - + func mappedState(for task: URLSessionTask, errorOccurred: Bool = false) -> String { if errorOccurred { return "FAILED" } switch task.state { @@ -98,54 +108,55 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { var providedId: String = "" var downloadedSeconds: Double = 0 var totalSeconds: Double = 0 - init(module: HlsDownloaderModule) { self.module = module } - - func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, - didLoad timeRange: CMTimeRange, - totalTimeRangesLoaded loadedTimeRanges: [NSValue], - timeRangeExpectedToLoad: CMTimeRange) { - var loadedSeconds = 0.0 - for value in loadedTimeRanges { - loadedSeconds += CMTimeGetSeconds(value.timeRangeValue.duration) + + func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) { + let downloaded = loadedTimeRanges.reduce(0.0) { total, value in + let timeRange = value.timeRangeValue + return total + CMTimeGetSeconds(timeRange.duration) + } + + let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration) + let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:] + + self.downloadedSeconds = downloaded + self.totalSeconds = total + + let progress = total > 0 ? downloaded / total : 0 + + module?.sendEvent("onProgress", [ + "id": providedId, + "progress": progress, + "bytesDownloaded": downloaded, + "bytesTotal": total, + "state": progress >= 1.0 ? "DONE" : "DOWNLOADING", + "metadata": metadata + ]) } - let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration) - downloadedSeconds = loadedSeconds - totalSeconds = total - let progress = total > 0 ? loadedSeconds / total : 0 - let state = module?.mappedState(for: assetDownloadTask) ?? "PENDING" - - module?.sendEvent("onProgress", [ - "id": providedId, - "progress": progress, - "bytesDownloaded": loadedSeconds, - "bytesTotal": total, - "state": state - ]) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - if let error = error { - let state = module?.mappedState(for: task, errorOccurred: true) ?? "FAILED" - module?.sendEvent("onError", [ - "id": providedId, - "error": error.localizedDescription, - "state": state - ]) + + func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { + let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:] + module?.sendEvent("onComplete", [ + "id": providedId, + "location": location.absoluteString, + "state": "DONE", + "metadata": metadata + ]) + module?.removeDownload(with: assetDownloadTask.taskIdentifier) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error = error { + let metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:] + module?.sendEvent("onError", [ + "id": providedId, + "error": error.localizedDescription, + "state": "FAILED", + "metadata": metadata + ]) + module?.removeDownload(with: taskIdentifier) + } } - module?.removeDownload(with: task.taskIdentifier) - } - - func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, - didFinishDownloadingTo location: URL) { - let state = module?.mappedState(for: assetDownloadTask) ?? "DONE" - module?.sendEvent("onComplete", [ - "id": providedId, - "location": location.absoluteString, - "state": state - ]) - module?.removeDownload(with: assetDownloadTask.taskIdentifier) - } } diff --git a/modules/hls-downloader/src/HlsDownloader.types.ts b/modules/hls-downloader/src/HlsDownloader.types.ts index 3adb48b1..9e2d9309 100644 --- a/modules/hls-downloader/src/HlsDownloader.types.ts +++ b/modules/hls-downloader/src/HlsDownloader.types.ts @@ -1,16 +1,33 @@ -export type OnProgressEventPayload = { - progress: number; - state: "PENDING" | "DOWNLOADING" | "PAUSED" | "DONE" | "FAILED" | "STOPPED"; +export type DownloadState = + | "PENDING" + | "DOWNLOADING" + | "PAUSED" + | "DONE" + | "FAILED" + | "STOPPED"; + +export interface DownloadMetadata { + Name: string; + [key: string]: unknown; +} + +export type BaseEventPayload = { id: string; + state: DownloadState; + metadata?: DownloadMetadata; +}; + +export type OnProgressEventPayload = BaseEventPayload & { + progress: number; bytesDownloaded: number; bytesTotal: number; }; -export type OnErrorEventPayload = { +export type OnErrorEventPayload = BaseEventPayload & { error: string; }; -export type OnCompleteEventPayload = { +export type OnCompleteEventPayload = BaseEventPayload & { location: string; }; @@ -19,3 +36,15 @@ export type HlsDownloaderModuleEvents = { onError: (params: OnErrorEventPayload) => void; onComplete: (params: OnCompleteEventPayload) => void; }; + +// Export a common interface that can be used by both HLS and regular downloads +export interface DownloadInfo { + id: string; + progress: number; + state: DownloadState; + bytesDownloaded?: number; + bytesTotal?: number; + location?: string; + error?: string; + metadata?: DownloadMetadata; +} diff --git a/providers/NativeDownloadProvider.tsx b/providers/NativeDownloadProvider.tsx new file mode 100644 index 00000000..ca4e00a0 --- /dev/null +++ b/providers/NativeDownloadProvider.tsx @@ -0,0 +1,255 @@ +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import RNBackgroundDownloader, { + DownloadTaskState, +} from "@kesha-antonov/react-native-background-downloader"; +import { createContext, useContext, useEffect, useState } from "react"; +import { + addCompleteListener, + addErrorListener, + addProgressListener, + checkForExistingDownloads, + downloadHLSAsset, +} from "@/modules/hls-downloader"; +import * as FileSystem from "expo-file-system"; +import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types"; + +type DownloadContextType = { + downloads: Record; + startDownload: (item: BaseItemDto, url: string) => Promise; + cancelDownload: (id: string) => void; +}; + +const DownloadContext = createContext( + undefined +); + +const persistDownloadedFile = async ( + originalLocation: string, + fileName: string +) => { + const destinationDir = `${FileSystem.documentDirectory}downloads/`; + const newLocation = `${destinationDir}${fileName}`; + + try { + // Ensure the downloads directory exists + await FileSystem.makeDirectoryAsync(destinationDir, { + intermediates: true, + }); + + // Move the file to its final destination + await FileSystem.moveAsync({ + from: originalLocation, + to: newLocation, + }); + + return newLocation; + } catch (error) { + console.error("Error persisting file:", error); + throw error; + } +}; + +export const NativeDownloadProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const [downloads, setDownloads] = useState>({}); + + 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) => ({ + ...acc, + [download.id]: { + id: download.id, + progress: download.progress, + state: download.state, + }, + }), + {} + ); + + // Check regular downloads + const regularDownloads = + await RNBackgroundDownloader.checkForExistingDownloads(); + const regularDownloadStates = regularDownloads.reduce( + (acc, download) => ({ + ...acc, + [download.id]: { + id: download.id, + progress: download.bytesDownloaded / download.bytesTotal, + state: download.state, + }, + }), + {} + ); + + setDownloads({ ...hlsDownloadStates, ...regularDownloadStates }); + }; + + initializeDownloads(); + + // Set up HLS download listeners + const progressListener = addProgressListener((download) => { + console.log("[HLS] Download progress:", download); + setDownloads((prev) => ({ + ...prev, + [download.id]: { + id: download.id, + progress: download.progress, + state: download.state, + }, + })); + }); + + const completeListener = addCompleteListener(async (payload) => { + if (typeof payload === "string") { + // Handle string ID (old HLS downloads) + setDownloads((prev) => { + const newDownloads = { ...prev }; + delete newDownloads[payload]; + return newDownloads; + }); + } else { + // Handle OnCompleteEventPayload (with location) + console.log("Download complete event received:", payload); + console.log("Original download location:", payload.location); + + try { + // Get the download info from our state + const downloadInfo = downloads[payload.id]; + if (downloadInfo?.metadata?.Name) { + const newLocation = await persistDownloadedFile( + payload.location, + downloadInfo.metadata.Name + ); + console.log("File successfully persisted to:", newLocation); + } else { + console.log("No filename in metadata, using original location"); + } + } catch (error) { + console.error("Failed to persist file:", error); + } + + setDownloads((prev) => { + const newDownloads = { ...prev }; + delete newDownloads[payload.id]; + return newDownloads; + }); + } + }); + + const errorListener = addErrorListener((error) => { + console.error("Download error:", error); + if (error.id) { + setDownloads((prev) => { + const newDownloads = { ...prev }; + delete newDownloads[error.id]; + return newDownloads; + }); + } + }); + + return () => { + progressListener.remove(); + completeListener.remove(); + errorListener.remove(); + }; + }, []); + + const startDownload = async (item: BaseItemDto, url: string) => { + if (!item.Id || !item.Name) throw new Error("Item ID or Name is missing"); + const jobId = item.Id; + + if (url.includes("master.m3u8")) { + // HLS download + downloadHLSAsset(jobId, url, item.Name, { + Name: item.Name, + }); + } else { + // Regular download + try { + const task = RNBackgroundDownloader.download({ + id: jobId, + url: url, + destination: `${FileSystem.documentDirectory}${jobId}`, + }); + + task.begin(({ expectedBytes }) => { + setDownloads((prev) => ({ + ...prev, + [jobId]: { + id: jobId, + progress: 0, + state: "DOWNLOADING", + }, + })); + }); + + task.progress(({ bytesDownloaded, bytesTotal }) => { + console.log( + "[Normal] Download progress:", + bytesDownloaded, + bytesTotal + ); + setDownloads((prev) => ({ + ...prev, + [jobId]: { + id: jobId, + progress: bytesDownloaded / bytesTotal, + state: "DOWNLOADING", + }, + })); + }); + + task.done(() => { + setDownloads((prev) => { + const newDownloads = { ...prev }; + delete newDownloads[jobId]; + return newDownloads; + }); + }); + + task.error(({ error }) => { + console.error("Download error:", error); + setDownloads((prev) => { + const newDownloads = { ...prev }; + delete newDownloads[jobId]; + return newDownloads; + }); + }); + } catch (error) { + console.error("Error starting download:", error); + } + } + }; + + const cancelDownload = (id: string) => { + // Implement cancel logic here + setDownloads((prev) => { + const newDownloads = { ...prev }; + delete newDownloads[id]; + return newDownloads; + }); + }; + + return ( + + {children} + + ); +}; + +export const useNativeDownloads = () => { + const context = useContext(DownloadContext); + if (context === undefined) { + throw new Error( + "useDownloads must be used within a NativeDownloadProvider" + ); + } + return context; +};