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

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