From 9625eaa30c614374f2f5d1bb1b798222f5338fa4 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 17 Feb 2025 16:09:50 +0100 Subject: [PATCH] wip --- modules/hls-downloader/android/build.gradle | 83 +++++++ .../android/src/main/AndroidManifest.xml | 2 + .../hls-downloader/HlsDownloaderModule.kt | 206 ++++++++++++++++++ .../ios/HlsDownloaderModule.swift | 128 ++++------- 4 files changed, 333 insertions(+), 86 deletions(-) create mode 100644 modules/hls-downloader/android/build.gradle create mode 100644 modules/hls-downloader/android/src/main/AndroidManifest.xml create mode 100644 modules/hls-downloader/android/src/main/java/expo/modules/hls-downloader/HlsDownloaderModule.kt 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 }