This commit is contained in:
Fredrik Burmester
2025-02-15 20:52:23 +01:00
parent f8597834d6
commit 10bf8fa19a
3 changed files with 163 additions and 60 deletions

View File

@@ -45,6 +45,10 @@ import {
useDownloadProgress,
useDownloadError,
useDownloadComplete,
addCompleteListener,
addErrorListener,
addProgressListener,
checkForExistingDownloads,
} from "@/modules/hls-downloader";
interface NativeDownloadButton extends ViewProps {
@@ -56,8 +60,7 @@ interface NativeDownloadButton extends ViewProps {
type DownloadState = {
id: string;
bytesDownloaded: number;
bytesTotal: number;
progress: number;
state: DownloadTaskState;
metadata?: {};
};
@@ -111,10 +114,6 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
bottomSheetModalRef.current?.dismiss();
}, []);
const progress = useDownloadProgress();
const complete = useDownloadComplete("download");
const downloadError = useDownloadError();
const acceptDownloadOptions = useCallback(async () => {
if (userCanDownload === true) {
closeModal();
@@ -146,6 +145,7 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
if (res.url.includes("master.m3u8")) {
// TODO: Download with custom native module
downloadHLSAsset(
item.Id!,
res.url,
`${FileSystem.documentDirectory}${item.Name}.mkv`
);
@@ -166,8 +166,7 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
toast.success("Download started");
setActiveDownload({
id: jobId,
bytesDownloaded: 0,
bytesTotal: expectedBytes,
progress: 0,
state: "DOWNLOADING",
});
})
@@ -202,17 +201,55 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
]);
useEffect(() => {
console.log(progress);
}, [progress]);
const progressListener = addProgressListener((item) => {
console.log("progress ~", item);
setActiveDownload({
id: activeDownload?.id!,
progress: item.progress,
state: "DOWNLOADING",
});
});
checkForExistingDownloads().then((downloads) => {
console.log(
"AVAssetDownloadURLSession ~ checkForExistingDownloads ~",
downloads
);
const firstDownload = downloads?.[0];
if (!download) 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("checkForExistingDownloads ~", downloads);
console.log(
"RNBackgroundDownloader ~ checkForExistingDownloads ~",
downloads
);
const e = downloads?.[0];
setActiveDownload({
id: e?.id,
bytesDownloaded: e?.bytesDownloaded,
bytesTotal: e?.bytesTotal,
progress: e?.bytesDownloaded / e?.bytesTotal,
state: e?.state,
});
@@ -220,8 +257,7 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
console.log(`Downloaded: ${bytesDownloaded} of ${bytesTotal}`);
setActiveDownload({
id: e?.id,
bytesDownloaded,
bytesTotal,
progress: bytesDownloaded / bytesTotal,
state: e?.state,
});
});
@@ -271,14 +307,10 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
size={size}
onPress={onButtonPress}
>
{activeDownload &&
activeDownload?.bytesTotal > 0 &&
activeDownload?.bytesDownloaded > 0 ? (
{activeDownload && activeDownload?.progress > 0 ? (
<ProgressCircle
size={24}
fill={
(activeDownload.bytesDownloaded / activeDownload.bytesTotal) * 100
}
fill={activeDownload.progress * 100}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"

View File

@@ -11,11 +11,29 @@ import HlsDownloaderModule from "./src/HlsDownloaderModule";
/**
* Initiates an HLS download.
* @param id - A unique identifier for the download.
* @param url - The HLS stream URL.
* @param assetTitle - A title for the asset.
*/
function downloadHLSAsset(url: string, assetTitle: string): void {
HlsDownloaderModule.downloadHLSAsset(url, assetTitle);
function downloadHLSAsset(id: string, url: string, assetTitle: string): void {
HlsDownloaderModule.downloadHLSAsset(id, url, assetTitle);
}
/**
* Checks for existing downloads.
* Returns an array of downloads with additional fields:
* id, progress, bytesDownloaded, bytesTotal, and state.
*/
async function checkForExistingDownloads(): Promise<
Array<{
id: string;
progress: number;
bytesDownloaded: number;
bytesTotal: number;
state: "PENDING" | "DOWNLOADING" | "PAUSED" | "DONE" | "FAILED" | "STOPPED";
}>
> {
return HlsDownloaderModule.checkForExistingDownloads();
}
/**
@@ -118,7 +136,7 @@ function useDownloadComplete(destinationFileName?: string): string | null {
console.log("Setting up download complete listener");
const subscription = addCompleteListener(
async (event: { location: string }) => {
async (event: OnCompleteEventPayload) => {
console.log("Download complete event received:", event);
console.log("Original download location:", event.location);
@@ -162,7 +180,12 @@ function useDownloadComplete(destinationFileName?: string): string | null {
export {
downloadHLSAsset,
checkForExistingDownloads,
useDownloadComplete,
useDownloadError,
useDownloadProgress,
addCompleteListener,
addErrorListener,
addProgressListener,
HlsDownloaderModule,
};

View File

@@ -1,39 +1,31 @@
// ios/HlsDownloaderModule.swift
import ExpoModulesCore
import AVFoundation
public class HlsDownloaderModule: Module {
// Optional: Keep a strong reference to the delegate (for the current download)
private var currentDelegate: HLSDownloadDelegate?
var activeDownloads: [Int: (task: AVAssetDownloadTask, delegate: HLSDownloadDelegate)] = [:]
public func definition() -> ModuleDefinition {
Name("HlsDownloader")
// Declare the events you wish to expose.
Events("onProgress", "onError", "onComplete")
// Expose the download function.
Function("downloadHLSAsset") { (url: String, assetTitle: String) -> Void in
print("[HlsDownloaderModule] downloadHLSAsset called with url: \(url) and assetTitle: \(assetTitle)")
Function("downloadHLSAsset") { (providedId: String, url: String, assetTitle: String) -> Void in
guard let assetURL = URL(string: url) else {
print("[HlsDownloaderModule] Invalid URL: \(url)")
self.sendEvent("onError", ["error": "Invalid URL"])
self.sendEvent("onError", ["id": providedId, "error": "Invalid URL", "state": "FAILED"])
return
}
let asset = AVURLAsset(url: assetURL)
let configuration = URLSessionConfiguration.background(withIdentifier: "com.example.hlsdownload.\(UUID().uuidString)")
print("[HlsDownloaderModule] Created background session configuration")
let configuration = URLSessionConfiguration.background(withIdentifier: "com.example.hlsdownload")
let delegate = HLSDownloadDelegate(module: self)
self.currentDelegate = delegate
delegate.providedId = providedId
let downloadSession = AVAssetDownloadURLSession(
configuration: configuration,
assetDownloadDelegate: delegate,
delegateQueue: OperationQueue.main
)
print("[HlsDownloaderModule] Created download session")
guard let task = downloadSession.makeAssetDownloadTask(
asset: asset,
@@ -41,32 +33,68 @@ public class HlsDownloaderModule: Module {
assetArtworkData: nil,
options: nil
) else {
print("[HlsDownloaderModule] Failed to create download task")
self.sendEvent("onError", ["error": "Failed to create download task"])
self.sendEvent("onError", ["id": providedId, "error": "Failed to create download task", "state": "FAILED"])
return
}
print("[HlsDownloaderModule] Starting download task for asset: \(assetTitle)")
delegate.taskIdentifier = task.taskIdentifier
self.activeDownloads[task.taskIdentifier] = (task, delegate)
self.sendEvent("onProgress", [
"id": providedId,
"progress": 0.0,
"state": "PENDING"
])
task.resume()
}
// Called when JavaScript starts observing events.
OnStartObserving {
print("[HlsDownloaderModule] Started observing events")
// Additional setup if needed.
Function("checkForExistingDownloads") {
() -> [[String: Any]] in
var downloads: [[String: Any]] = []
for (id, pair) in self.activeDownloads {
let task = pair.task
let delegate = pair.delegate
let downloaded = delegate.downloadedSeconds
let total = delegate.totalSeconds
let progress = total > 0 ? downloaded / total : 0
downloads.append([
"id": delegate.providedId.isEmpty ? String(id) : delegate.providedId,
"progress": progress,
"bytesDownloaded": downloaded,
"bytesTotal": total,
"state": self.mappedState(for: task)
])
}
return downloads
}
// Called when JavaScript stops observing events.
OnStopObserving {
print("[HlsDownloaderModule] Stopped observing events")
// Clean up if necessary.
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 {
case .running: return "DOWNLOADING"
case .suspended: return "PAUSED"
case .completed: return "DONE"
case .canceling: return "STOPPED"
@unknown default: return "PENDING"
}
}
}
// Delegate that listens to AVAssetDownloadURLSession events and emits them to JS.
class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
weak var module: HlsDownloaderModule?
var taskIdentifier: Int = 0
var providedId: String = ""
var downloadedSeconds: Double = 0
var totalSeconds: Double = 0
init(module: HlsDownloaderModule) {
self.module = module
@@ -76,25 +104,45 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
didLoad timeRange: CMTimeRange,
totalTimeRangesLoaded loadedTimeRanges: [NSValue],
timeRangeExpectedToLoad: CMTimeRange) {
let loadedSeconds = loadedTimeRanges.reduce(0.0) { result, value in
result + CMTimeGetSeconds(value.timeRangeValue.duration)
var loadedSeconds = 0.0
for value in loadedTimeRanges {
loadedSeconds += CMTimeGetSeconds(value.timeRangeValue.duration)
}
let totalSeconds = CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
let progress = totalSeconds > 0 ? loadedSeconds / totalSeconds : 0
print("[HLSDownloadDelegate] Progress: \(progress * 100)%")
module?.sendEvent("onProgress", ["progress": progress])
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 {
print("[HLSDownloadDelegate] Error: \(error.localizedDescription)")
module?.sendEvent("onError", ["error": error.localizedDescription])
let state = module?.mappedState(for: task, errorOccurred: true) ?? "FAILED"
module?.sendEvent("onError", [
"id": providedId,
"error": error.localizedDescription,
"state": state
])
}
module?.removeDownload(with: task.taskIdentifier)
}
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
didFinishDownloadingTo location: URL) {
print("[HLSDownloadDelegate] Download complete: \(location.absoluteString)")
module?.sendEvent("onComplete", ["location": location.absoluteString])
let state = module?.mappedState(for: assetDownloadTask) ?? "DONE"
module?.sendEvent("onComplete", [
"id": providedId,
"location": location.absoluteString,
"state": state
])
module?.removeDownload(with: assetDownloadTask.taskIdentifier)
}
}