diff --git a/modules/hls-downloader/android/build.gradle b/modules/hls-downloader/android/build.gradle
new file mode 100644
index 00000000..9d131945
--- /dev/null
+++ b/modules/hls-downloader/android/build.gradle
@@ -0,0 +1,83 @@
+plugins {
+ id 'com.android.library'
+ id 'kotlin-android'
+ id 'kotlin-kapt'
+}
+
+group = 'expo.modules.hlsdownloader'
+version = '0.1.0'
+
+def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
+
+apply from: expoModulesCorePlugin
+
+applyKotlinExpoModulesCorePlugin()
+useCoreDependencies()
+useExpoPublishing()
+
+def useManagedAndroidSdkVersions = false
+if (useManagedAndroidSdkVersions) {
+ useDefaultAndroidSdkVersions()
+} else {
+ buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath "com.android.tools.build:gradle:7.1.3"
+ }
+ }
+ project.android {
+ compileSdkVersion safeExtGet("compileSdkVersion", 34)
+ defaultConfig {
+ minSdkVersion safeExtGet("minSdkVersion", 21)
+ targetSdkVersion safeExtGet("targetSdkVersion", 34)
+ }
+ }
+}
+
+dependencies {
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
+
+ // Media3 dependencies
+ def media3Version = "1.2.0"
+ implementation "androidx.media3:media3-exoplayer:$media3Version"
+ implementation "androidx.media3:media3-datasource:$media3Version"
+ implementation "androidx.media3:media3-common:$media3Version"
+ implementation "androidx.media3:media3-database:$media3Version"
+ implementation "androidx.media3:media3-decoder:$media3Version"
+ implementation "androidx.media3:media3-ui:$media3Version"
+
+ // Coroutines for background processing
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
+}
+
+android {
+ namespace "expo.modules.hlsdownloader"
+ compileSdkVersion 34
+ defaultConfig {
+ minSdkVersion 21
+ targetSdkVersion 34
+ versionCode 1
+ versionName "0.1.0"
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
+
+tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
+ kotlinOptions {
+ freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
+ jvmTarget = "17"
+ }
+}
\ No newline at end of file
diff --git a/modules/hls-downloader/android/src/main/AndroidManifest.xml b/modules/hls-downloader/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..bdae66c8
--- /dev/null
+++ b/modules/hls-downloader/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/modules/hls-downloader/android/src/main/java/expo/modules/hls-downloader/HlsDownloaderModule.kt b/modules/hls-downloader/android/src/main/java/expo/modules/hls-downloader/HlsDownloaderModule.kt
new file mode 100644
index 00000000..7abcc29f
--- /dev/null
+++ b/modules/hls-downloader/android/src/main/java/expo/modules/hls-downloader/HlsDownloaderModule.kt
@@ -0,0 +1,206 @@
+package com.example.hlsdownloader
+
+import android.content.Context
+import android.net.Uri
+import android.os.Handler
+import android.os.Looper
+import androidx.media3.common.MediaItem
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.offline.Download
+import androidx.media3.exoplayer.offline.DownloadManager
+import androidx.media3.exoplayer.offline.DownloadService
+import androidx.media3.datasource.DefaultHttpDataSource
+import androidx.media3.datasource.cache.SimpleCache
+import androidx.media3.datasource.cache.NoOpCacheEvictor
+import androidx.media3.exoplayer.offline.DownloadRequest
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.modules.core.DeviceEventManagerModule
+import java.io.File
+import org.json.JSONObject
+import java.util.concurrent.ConcurrentHashMap
+
+@UnstableApi
+class HlsDownloaderModule(private val context: ReactContext) {
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val downloadCache: SimpleCache
+ private val downloadManager: DownloadManager
+ private val activeDownloads = ConcurrentHashMap()
+
+ data class DownloadInfo(
+ val metadata: Map,
+ val startTime: Long,
+ var downloadRequest: DownloadRequest? = null
+ )
+
+ init {
+ // Initialize download cache
+ val downloadDirectory = File(context.filesDir, "downloads")
+ if (!downloadDirectory.exists()) {
+ downloadDirectory.mkdirs()
+ }
+
+ downloadCache = SimpleCache(
+ downloadDirectory,
+ NoOpCacheEvictor(),
+ DefaultHttpDataSource.Factory()
+ )
+
+ // Initialize download manager
+ downloadManager = DownloadManager(
+ context,
+ createDatabaseProvider(),
+ downloadCache,
+ DefaultHttpDataSource.Factory(),
+ null
+ )
+
+ // Start tracking downloads
+ downloadManager.addListener(object : DownloadManager.Listener {
+ override fun onDownloadChanged(
+ downloadManager: DownloadManager,
+ download: Download,
+ finalException: Exception?
+ ) {
+ val downloadInfo = activeDownloads[download.request.id] ?: return
+
+ when (download.state) {
+ Download.STATE_DOWNLOADING -> {
+ sendEvent("onProgress", mapOf(
+ "id" to download.request.id,
+ "progress" to (download.percentDownloaded / 100.0),
+ "state" to "DOWNLOADING",
+ "metadata" to downloadInfo.metadata,
+ "startTime" to downloadInfo.startTime
+ ))
+ }
+ Download.STATE_COMPLETED -> {
+ handleCompletedDownload(download, downloadInfo)
+ }
+ Download.STATE_FAILED -> {
+ handleFailedDownload(download, downloadInfo, finalException)
+ }
+ }
+ }
+ })
+ }
+
+ fun downloadHLSAsset(providedId: String, url: String, metadata: Map?) {
+ val startTime = System.currentTimeMillis()
+
+ // Check if download already exists
+ val downloadDir = File(context.filesDir, "downloads/$providedId")
+ if (downloadDir.exists() && downloadDir.list()?.any { it.endsWith(".m3u8") } == true) {
+ sendEvent("onComplete", mapOf(
+ "id" to providedId,
+ "location" to downloadDir.absolutePath,
+ "state" to "DONE",
+ "metadata" to (metadata ?: emptyMap()),
+ "startTime" to startTime
+ ))
+ return
+ }
+
+ try {
+ val mediaItem = MediaItem.fromUri(Uri.parse(url))
+ val downloadRequest = DownloadRequest.Builder(providedId, mediaItem.mediaId)
+ .setCustomCacheKey(providedId)
+ .setData(metadata?.toString()?.toByteArray() ?: ByteArray(0))
+ .build()
+
+ activeDownloads[providedId] = DownloadInfo(
+ metadata = metadata ?: emptyMap(),
+ startTime = startTime,
+ downloadRequest = downloadRequest
+ )
+
+ downloadManager.addDownload(downloadRequest)
+
+ sendEvent("onProgress", mapOf(
+ "id" to providedId,
+ "progress" to 0.0,
+ "state" to "PENDING",
+ "metadata" to (metadata ?: emptyMap()),
+ "startTime" to startTime
+ ))
+ } catch (e: Exception) {
+ sendEvent("onError", mapOf(
+ "id" to providedId,
+ "error" to e.localizedMessage,
+ "state" to "FAILED",
+ "metadata" to (metadata ?: emptyMap()),
+ "startTime" to startTime
+ ))
+ }
+ }
+
+ fun cancelDownload(providedId: String) {
+ val downloadInfo = activeDownloads[providedId] ?: return
+ downloadInfo.downloadRequest?.let { request ->
+ downloadManager.removeDownload(request.id)
+ sendEvent("onError", mapOf(
+ "id" to providedId,
+ "error" to "Download cancelled",
+ "state" to "CANCELLED",
+ "metadata" to downloadInfo.metadata,
+ "startTime" to downloadInfo.startTime
+ ))
+ activeDownloads.remove(providedId)
+ }
+ }
+
+ private fun handleCompletedDownload(download: Download, downloadInfo: DownloadInfo) {
+ try {
+ val downloadDir = File(context.filesDir, "downloads/${download.request.id}")
+ if (!downloadDir.exists()) {
+ downloadDir.mkdirs()
+ }
+
+ // Save metadata if present
+ downloadInfo.metadata.takeIf { it.isNotEmpty() }?.let { metadata ->
+ val metadataFile = File(downloadDir, "${download.request.id}.json")
+ metadataFile.writeText(JSONObject(metadata).toString())
+ }
+
+ sendEvent("onComplete", mapOf(
+ "id" to download.request.id,
+ "location" to downloadDir.absolutePath,
+ "state" to "DONE",
+ "metadata" to downloadInfo.metadata,
+ "startTime" to downloadInfo.startTime
+ ))
+ } catch (e: Exception) {
+ handleFailedDownload(download, downloadInfo, e)
+ } finally {
+ activeDownloads.remove(download.request.id)
+ }
+ }
+
+ private fun handleFailedDownload(
+ download: Download,
+ downloadInfo: DownloadInfo,
+ error: Exception?
+ ) {
+ sendEvent("onError", mapOf(
+ "id" to download.request.id,
+ "error" to (error?.localizedMessage ?: "Unknown error"),
+ "state" to "FAILED",
+ "metadata" to downloadInfo.metadata,
+ "startTime" to downloadInfo.startTime
+ ))
+ activeDownloads.remove(download.request.id)
+ }
+
+ private fun createDatabaseProvider() = StandaloneDatabaseProvider(context)
+
+ private fun sendEvent(eventName: String, params: Map) {
+ mainHandler.post {
+ context
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
+ .emit(eventName, params)
+ }
+ }
+
+ companion object {
+ private const val DOWNLOAD_CONTENT_DIRECTORY = "downloads"
+ }
+}
\ No newline at end of file
diff --git a/modules/hls-downloader/ios/HlsDownloaderModule.swift b/modules/hls-downloader/ios/HlsDownloaderModule.swift
index 65ad6cc3..d4f9cb9b 100644
--- a/modules/hls-downloader/ios/HlsDownloaderModule.swift
+++ b/modules/hls-downloader/ios/HlsDownloaderModule.swift
@@ -1,6 +1,7 @@
import AVFoundation
import ExpoModulesCore
+// Separate delegate class for managing download-specific state
class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
weak var module: HlsDownloaderModule?
var taskIdentifier: Int = 0
@@ -39,16 +40,21 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
}
public class HlsDownloaderModule: Module {
- private lazy var delegateHandler: HlsDownloaderDelegate = {
- return HlsDownloaderDelegate(module: self)
+ // Main delegate handler for the download session
+ private lazy var delegateHandler: HLSDownloadDelegate = {
+ return HLSDownloadDelegate(module: self)
}()
+ // Track active downloads with all necessary information
var activeDownloads:
[Int: (
- task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any],
+ task: AVAssetDownloadTask,
+ delegate: HLSDownloadDelegate,
+ metadata: [String: Any],
startTime: Double
)] = [:]
+ // Configure background download session
private lazy var downloadSession: AVAssetDownloadURLSession = {
let configuration = URLSessionConfiguration.background(
withIdentifier: "com.example.hlsdownload")
@@ -70,11 +76,15 @@ public class HlsDownloaderModule: Module {
Function("downloadHLSAsset") {
(providedId: String, url: String, metadata: [String: Any]?) -> Void in
let startTime = Date().timeIntervalSince1970
+
+ // Check if download already exists
let fm = FileManager.default
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0]
let downloadsDir = docs.appendingPathComponent("downloads", isDirectory: true)
let potentialExistingLocation = downloadsDir.appendingPathComponent(
providedId, isDirectory: true)
+
+ // If download exists and is valid, return immediately
if fm.fileExists(atPath: potentialExistingLocation.path) {
if let files = try? fm.contentsOfDirectory(atPath: potentialExistingLocation.path),
files.contains(where: { $0.hasSuffix(".m3u8") })
@@ -94,6 +104,7 @@ public class HlsDownloaderModule: Module {
}
}
+ // Validate URL
guard let assetURL = URL(string: url) else {
self.sendEvent(
"onError",
@@ -107,14 +118,16 @@ public class HlsDownloaderModule: Module {
return
}
+ // Configure asset with necessary options
let asset = AVURLAsset(
url: assetURL,
options: [
"AVURLAssetOutOfBandMIMETypeKey": "application/x-mpegURL",
- "AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "Streamyfin/1.0"],
+ "AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "MyApp/1.0"],
"AVURLAssetAllowsCellularAccessKey": true,
])
+ // Load asset asynchronously
asset.loadValuesAsynchronously(forKeys: ["playable", "duration"]) {
var error: NSError?
let status = asset.statusOfValue(forKey: "playable", error: &error)
@@ -134,6 +147,7 @@ public class HlsDownloaderModule: Module {
return
}
+ // Create download task with quality options
guard
let task = self.downloadSession.makeAssetDownloadTask(
asset: asset,
@@ -159,13 +173,16 @@ public class HlsDownloaderModule: Module {
return
}
+ // Configure delegate for this download
let delegate = HLSDownloadDelegate(module: self)
delegate.providedId = providedId
delegate.startTime = startTime
delegate.taskIdentifier = task.taskIdentifier
+ // Store download information
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime)
+ // Send initial progress event
self.sendEvent(
"onProgress",
[
@@ -176,19 +193,21 @@ public class HlsDownloaderModule: Module {
"startTime": startTime,
])
+ // Start the download
task.resume()
}
}
}
+ // Additional methods and event handlers...
Function("cancelDownload") { (providedId: String) -> Void in
guard
let entry = self.activeDownloads.first(where: { $0.value.delegate.providedId == providedId }
)
else {
- print("No active download found with identifier: \(providedId)")
return
}
+
let (task, _, metadata, startTime) = entry.value
self.sendEvent(
@@ -203,13 +222,10 @@ public class HlsDownloaderModule: Module {
task.cancel()
self.activeDownloads.removeValue(forKey: task.taskIdentifier)
- print("Download cancelled for identifier: \(providedId)")
}
-
- OnStartObserving {}
- OnStopObserving {}
}
+ // Helper methods
func removeDownload(with id: Int) {
activeDownloads.removeValue(forKey: id)
}
@@ -218,9 +234,11 @@ public class HlsDownloaderModule: Module {
let fm = FileManager.default
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0]
let downloadsDir = docs.appendingPathComponent("downloads", isDirectory: true)
+
if !fm.fileExists(atPath: downloadsDir.path) {
try fm.createDirectory(at: downloadsDir, withIntermediateDirectories: true)
}
+
let newLocation = downloadsDir.appendingPathComponent(folderName, isDirectory: true)
let tempLocation = downloadsDir.appendingPathComponent("\(folderName)_temp", isDirectory: true)
@@ -238,55 +256,9 @@ public class HlsDownloaderModule: Module {
return newLocation
}
-
- 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"
- }
- }
-}
-class HlsDownloaderDelegate: NSObject, AVAssetDownloadDelegate {
- weak var module: HlsDownloaderModule?
- var taskIdentifier: Int = 0
- var providedId: String = ""
- var downloadedSeconds: Double = 0
- var totalSeconds: Double = 0
- var startTime: Double = 0
-
- init(module: HlsDownloaderModule) {
- self.module = module
- super.init()
- }
-
- public func urlSession(
- _ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange,
- totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange
- ) {
- module?.urlSession(
- session, assetDownloadTask: assetDownloadTask, didLoad: timeRange,
- totalTimeRangesLoaded: loadedTimeRanges, timeRangeExpectedToLoad: timeRangeExpectedToLoad)
- }
-
- public func urlSession(
- _ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
- didFinishDownloadingTo location: URL
- ) {
- module?.urlSession(
- session, assetDownloadTask: assetDownloadTask, didFinishDownloadingTo: location)
- }
-
- public func urlSession(
- _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?
- ) {
- module?.urlSession(session, task: task, didCompleteWithError: error)
- }
}
+// Extension for URL session delegate methods
extension HlsDownloaderModule {
func urlSession(
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange,
@@ -324,41 +296,27 @@ extension HlsDownloaderModule {
do {
let newLocation = try persistDownloadedFolder(
- originalLocation: location, folderName: downloadInfo.delegate.providedId)
+ originalLocation: location,
+ folderName: downloadInfo.delegate.providedId)
if !downloadInfo.metadata.isEmpty {
let metadataLocation = newLocation.deletingLastPathComponent().appendingPathComponent(
"\(downloadInfo.delegate.providedId).json")
let jsonData = try JSONSerialization.data(
- withJSONObject: downloadInfo.metadata, options: .prettyPrinted)
+ withJSONObject: downloadInfo.metadata,
+ options: .prettyPrinted)
try jsonData.write(to: metadataLocation)
}
- Task {
- do {
- try await rewriteM3U8Files(baseDir: newLocation.path)
-
- sendEvent(
- "onComplete",
- [
- "id": downloadInfo.delegate.providedId,
- "location": newLocation.absoluteString,
- "state": "DONE",
- "metadata": downloadInfo.metadata,
- "startTime": downloadInfo.startTime,
- ])
- } catch {
- sendEvent(
- "onError",
- [
- "id": downloadInfo.delegate.providedId,
- "error": error.localizedDescription,
- "state": "FAILED",
- "metadata": downloadInfo.metadata,
- "startTime": downloadInfo.startTime,
- ])
- }
- }
+ sendEvent(
+ "onComplete",
+ [
+ "id": downloadInfo.delegate.providedId,
+ "location": newLocation.absoluteString,
+ "state": "DONE",
+ "metadata": downloadInfo.metadata,
+ "startTime": downloadInfo.startTime,
+ ])
} catch {
sendEvent(
"onError",
@@ -374,9 +332,7 @@ extension HlsDownloaderModule {
removeDownload(with: assetDownloadTask.taskIdentifier)
}
- func urlSession(
- _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?
- ) {
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error = error,
let downloadInfo = activeDownloads[task.taskIdentifier]
else { return }