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() {
-
-
-
-
- null,
+
+
+
+
+
+ null,
+ }}
+ />
+ null,
+ }}
+ />
+
+
+
+
- null,
- }}
- />
-
-
-
-
-
-
+
+
+
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;
+};