This commit is contained in:
Fredrik Burmester
2025-02-17 16:09:50 +01:00
parent db6bc7901e
commit 9625eaa30c
4 changed files with 333 additions and 86 deletions

View File

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

View File

@@ -0,0 +1,2 @@
<manifest>
</manifest>

View File

@@ -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<String, DownloadInfo>()
data class DownloadInfo(
val metadata: Map<String, Any>,
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<String, Any>?) {
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<String, Any>) {
mainHandler.post {
context
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(eventName, params)
}
}
companion object {
private const val DOWNLOAD_CONTENT_DIRECTORY = "downloads"
}
}

View File

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