This commit is contained in:
Fredrik Burmester
2025-02-15 22:35:10 +01:00
parent 179f6c02ca
commit ca726e0ca5
6 changed files with 472 additions and 311 deletions

View File

@@ -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() {
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack>
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
header: () => null,
<NativeDownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack>
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/player"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="login"
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
<Stack.Screen
name="(auth)/player"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="login"
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</ThemeProvider>
</BottomSheetModalProvider>
</NativeDownloadProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>

View File

@@ -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<NativeDownloadButton> = ({
item,
title = "Download",
@@ -75,10 +47,7 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
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<NativeDownloadButton> = ({
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<NativeDownloadButton> = ({
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<NativeDownloadButton> = ({
[]
);
const onButtonPress = () => {
handlePresentModalPress();
};
const activeDownload = item.Id ? downloads[item.Id] : undefined;
return (
<View {...props}>
<RoundButton
disabled={userCanDownload === false || activeDownload?.id !== undefined}
disabled={userCanDownload === false || activeDownload !== undefined}
size={size}
onPress={onButtonPress}
onPress={handlePresentModalPress}
>
{activeDownload && activeDownload?.progress > 0 ? (
<ProgressCircle
size={24}
fill={activeDownload.progress * 100}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
{activeDownload ? (
<>
{activeDownload.state === "PENDING" && (
<ActivityIndicator size="small" color="white" />
)}
{activeDownload.state === "DOWNLOADING" && (
<ProgressCircle
size={24}
fill={activeDownload.progress * 100}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
)}
</>
) : (
<Ionicons name="cloud-download-outline" size={24} color="white" />
)}

View File

@@ -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);
}
/**

View File

@@ -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)
}
}

View File

@@ -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;
}

View File

@@ -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<string, DownloadInfo>;
startDownload: (item: BaseItemDto, url: string) => Promise<void>;
cancelDownload: (id: string) => void;
};
const DownloadContext = createContext<DownloadContextType | undefined>(
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<Record<string, DownloadInfo>>({});
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 (
<DownloadContext.Provider
value={{ downloads, startDownload, cancelDownload }}
>
{children}
</DownloadContext.Provider>
);
};
export const useNativeDownloads = () => {
const context = useContext(DownloadContext);
if (context === undefined) {
throw new Error(
"useDownloads must be used within a NativeDownloadProvider"
);
}
return context;
};