mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-21 16:24:41 +01:00
feat: Expo 54 (new arch) support + new in-house download module (#1174)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: sarendsen <coding-mosses0z@icloud.com> Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
154788cf91
commit
485dc6eeac
258
modules/background-downloader/README.md
Normal file
258
modules/background-downloader/README.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# Background Downloader Module
|
||||
|
||||
A native iOS and Android module for downloading large files in the background using `NSURLSession` (iOS) and `DownloadManager` (Android).
|
||||
|
||||
## Features
|
||||
|
||||
- **Background Downloads**: Downloads continue even when the app is backgrounded or suspended
|
||||
- **Progress Tracking**: Real-time progress updates via events
|
||||
- **Multiple Downloads**: Support for concurrent downloads
|
||||
- **Cancellation**: Cancel individual or all downloads
|
||||
- **Custom Destination**: Optionally specify custom file paths
|
||||
- **Error Handling**: Comprehensive error reporting
|
||||
- **Cross-Platform**: Works on both iOS and Android
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```typescript
|
||||
import { BackgroundDownloader } from '@/modules';
|
||||
|
||||
// Start a download
|
||||
const taskId = await BackgroundDownloader.startDownload(
|
||||
'https://example.com/largefile.mp4'
|
||||
);
|
||||
|
||||
// Listen for progress updates
|
||||
const progressSub = BackgroundDownloader.addProgressListener((event) => {
|
||||
console.log(`Progress: ${Math.floor(event.progress * 100)}%`);
|
||||
console.log(`Downloaded: ${event.bytesWritten} / ${event.totalBytes}`);
|
||||
});
|
||||
|
||||
// Listen for completion
|
||||
const completeSub = BackgroundDownloader.addCompleteListener((event) => {
|
||||
console.log('Download complete!');
|
||||
console.log('File saved to:', event.filePath);
|
||||
console.log('Task ID:', event.taskId);
|
||||
});
|
||||
|
||||
// Listen for errors
|
||||
const errorSub = BackgroundDownloader.addErrorListener((event) => {
|
||||
console.error('Download failed:', event.error);
|
||||
});
|
||||
|
||||
// Cancel a download
|
||||
BackgroundDownloader.cancelDownload(taskId);
|
||||
|
||||
// Get all active downloads
|
||||
const activeDownloads = await BackgroundDownloader.getActiveDownloads();
|
||||
|
||||
// Cleanup listeners when done
|
||||
progressSub.remove();
|
||||
completeSub.remove();
|
||||
errorSub.remove();
|
||||
```
|
||||
|
||||
### Custom Destination Path
|
||||
|
||||
```typescript
|
||||
import { BackgroundDownloader } from '@/modules';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
const destinationPath = `${FileSystem.documentDirectory}myfile.mp4`;
|
||||
const taskId = await BackgroundDownloader.startDownload(
|
||||
'https://example.com/video.mp4',
|
||||
destinationPath
|
||||
);
|
||||
```
|
||||
|
||||
### Managing Multiple Downloads
|
||||
|
||||
```typescript
|
||||
import { BackgroundDownloader } from '@/modules';
|
||||
|
||||
const downloads = new Map();
|
||||
|
||||
async function startMultipleDownloads(urls: string[]) {
|
||||
for (const url of urls) {
|
||||
const taskId = await BackgroundDownloader.startDownload(url);
|
||||
downloads.set(taskId, { url, progress: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// Track progress for each download
|
||||
const progressSub = BackgroundDownloader.addProgressListener((event) => {
|
||||
const download = downloads.get(event.taskId);
|
||||
if (download) {
|
||||
download.progress = event.progress;
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel all downloads
|
||||
BackgroundDownloader.cancelAllDownloads();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Methods
|
||||
|
||||
#### `startDownload(url: string, destinationPath?: string): Promise<number>`
|
||||
|
||||
Starts a new background download.
|
||||
|
||||
- **Parameters:**
|
||||
- `url`: The URL of the file to download
|
||||
- `destinationPath`: (Optional) Custom file path for the downloaded file
|
||||
- **Returns:** Promise that resolves to the task ID (number)
|
||||
|
||||
#### `cancelDownload(taskId: number): void`
|
||||
|
||||
Cancels a specific download by task ID.
|
||||
|
||||
- **Parameters:**
|
||||
- `taskId`: The task ID returned by `startDownload`
|
||||
|
||||
#### `cancelAllDownloads(): void`
|
||||
|
||||
Cancels all active downloads.
|
||||
|
||||
#### `getActiveDownloads(): Promise<ActiveDownload[]>`
|
||||
|
||||
Gets information about all active downloads.
|
||||
|
||||
- **Returns:** Promise that resolves to an array of active downloads
|
||||
|
||||
### Event Listeners
|
||||
|
||||
#### `addProgressListener(listener: (event: DownloadProgressEvent) => void): Subscription`
|
||||
|
||||
Listens for download progress updates.
|
||||
|
||||
- **Event payload:**
|
||||
- `taskId`: number
|
||||
- `bytesWritten`: number
|
||||
- `totalBytes`: number
|
||||
- `progress`: number (0.0 to 1.0)
|
||||
|
||||
#### `addCompleteListener(listener: (event: DownloadCompleteEvent) => void): Subscription`
|
||||
|
||||
Listens for download completion.
|
||||
|
||||
- **Event payload:**
|
||||
- `taskId`: number
|
||||
- `filePath`: string
|
||||
- `url`: string
|
||||
|
||||
#### `addErrorListener(listener: (event: DownloadErrorEvent) => void): Subscription`
|
||||
|
||||
Listens for download errors.
|
||||
|
||||
- **Event payload:**
|
||||
- `taskId`: number
|
||||
- `error`: string
|
||||
|
||||
#### `addStartedListener(listener: (event: DownloadStartedEvent) => void): Subscription`
|
||||
|
||||
Listens for download start confirmation.
|
||||
|
||||
- **Event payload:**
|
||||
- `taskId`: number
|
||||
- `url`: string
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
interface DownloadProgressEvent {
|
||||
taskId: number;
|
||||
bytesWritten: number;
|
||||
totalBytes: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface DownloadCompleteEvent {
|
||||
taskId: number;
|
||||
filePath: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface DownloadErrorEvent {
|
||||
taskId: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface DownloadStartedEvent {
|
||||
taskId: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ActiveDownload {
|
||||
taskId: number;
|
||||
url: string;
|
||||
state: 'running' | 'suspended' | 'canceling' | 'completed' | 'unknown';
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### iOS Background Downloads
|
||||
|
||||
- Uses `NSURLSession` with background configuration
|
||||
- Session identifier: `com.fredrikburmester.streamyfin.backgrounddownloader`
|
||||
- Downloads continue when app is backgrounded or suspended
|
||||
- System may terminate downloads if app is force-quit
|
||||
|
||||
### Android Background Downloads
|
||||
|
||||
- Uses Android's `DownloadManager` API
|
||||
- Downloads are managed by the system and continue in the background
|
||||
- Shows download notification in the notification tray
|
||||
- Downloads continue even if the app is closed
|
||||
- Requires `INTERNET` permission (automatically added by Expo)
|
||||
|
||||
### Background Modes
|
||||
|
||||
The app's `Info.plist` already includes the required background mode for iOS:
|
||||
|
||||
- `UIBackgroundModes`: `["audio", "fetch"]`
|
||||
|
||||
### File Storage
|
||||
|
||||
**iOS:** By default, downloaded files are saved to the app's Documents directory.
|
||||
|
||||
**Android:** By default, files are saved to the app's external files directory (accessible via `FileSystem.documentDirectory` in Expo).
|
||||
|
||||
You can specify a custom path using the `destinationPath` parameter on both platforms.
|
||||
|
||||
## Building
|
||||
|
||||
After adding this module, rebuild the app:
|
||||
|
||||
```bash
|
||||
# iOS
|
||||
npx expo prebuild -p ios
|
||||
npx expo run:ios
|
||||
|
||||
# Android
|
||||
npx expo prebuild -p android
|
||||
npx expo run:android
|
||||
```
|
||||
|
||||
Or install manually:
|
||||
|
||||
```bash
|
||||
# iOS
|
||||
cd ios
|
||||
pod install
|
||||
cd ..
|
||||
|
||||
# Android - prebuild handles everything
|
||||
npx expo prebuild -p android
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Background downloads may be cancelled if the user force-quits the app (iOS)
|
||||
- The OS manages download priority and may pause downloads to save battery
|
||||
- Android shows a system notification for ongoing downloads
|
||||
- Downloads over cellular are allowed by default on both platforms
|
||||
46
modules/background-downloader/android/build.gradle
Normal file
46
modules/background-downloader/android/build.gradle
Normal file
@@ -0,0 +1,46 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'kotlin-android'
|
||||
}
|
||||
|
||||
group = 'expo.modules.backgrounddownloader'
|
||||
version = '1.0.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()
|
||||
useDefaultAndroidSdkVersions()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
|
||||
android {
|
||||
namespace "expo.modules.backgrounddownloader"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||
}
|
||||
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name=".DownloadService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package expo.modules.backgrounddownloader
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import expo.modules.kotlin.Promise
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
data class DownloadTaskInfo(
|
||||
val url: String,
|
||||
val destinationPath: String?
|
||||
)
|
||||
|
||||
class BackgroundDownloaderModule : Module() {
|
||||
companion object {
|
||||
private const val TAG = "BackgroundDownloader"
|
||||
}
|
||||
|
||||
private val context
|
||||
get() = requireNotNull(appContext.reactContext)
|
||||
|
||||
private val downloadManager = OkHttpDownloadManager()
|
||||
private val downloadTasks = mutableMapOf<Int, DownloadTaskInfo>()
|
||||
private val downloadQueue = mutableListOf<Pair<String, String?>>()
|
||||
private var taskIdCounter = 1
|
||||
private var downloadService: DownloadService? = null
|
||||
private var serviceBound = false
|
||||
|
||||
private val serviceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
Log.d(TAG, "Service connected")
|
||||
val binder = service as DownloadService.DownloadServiceBinder
|
||||
downloadService = binder.getService()
|
||||
serviceBound = true
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
Log.d(TAG, "Service disconnected")
|
||||
downloadService = null
|
||||
serviceBound = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("BackgroundDownloader")
|
||||
|
||||
Events(
|
||||
"onDownloadProgress",
|
||||
"onDownloadComplete",
|
||||
"onDownloadError",
|
||||
"onDownloadStarted"
|
||||
)
|
||||
|
||||
OnCreate {
|
||||
Log.d(TAG, "Module created")
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
Log.d(TAG, "Module destroyed")
|
||||
downloadManager.cancelAllDownloads()
|
||||
if (serviceBound) {
|
||||
try {
|
||||
context.unbindService(serviceConnection)
|
||||
serviceBound = false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error unbinding service: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("startDownload") { urlString: String, destinationPath: String?, promise: Promise ->
|
||||
try {
|
||||
val taskId = startDownloadInternal(urlString, destinationPath)
|
||||
promise.resolve(taskId)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("DOWNLOAD_ERROR", "Failed to start download: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("enqueueDownload") { urlString: String, destinationPath: String?, promise: Promise ->
|
||||
try {
|
||||
Log.d(TAG, "Enqueuing download: url=$urlString")
|
||||
|
||||
// Add to queue
|
||||
val wasEmpty = downloadQueue.isEmpty()
|
||||
downloadQueue.add(Pair(urlString, destinationPath))
|
||||
Log.d(TAG, "Queue size: ${downloadQueue.size}")
|
||||
|
||||
// If queue was empty and no active downloads, start processing immediately
|
||||
if (wasEmpty && downloadTasks.isEmpty()) {
|
||||
val taskId = processNextInQueue()
|
||||
promise.resolve(taskId)
|
||||
} else {
|
||||
// Return placeholder taskId for queued items
|
||||
promise.resolve(-1)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
promise.reject("DOWNLOAD_ERROR", "Failed to enqueue download: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
Function("cancelDownload") { taskId: Int ->
|
||||
Log.d(TAG, "Cancelling download: taskId=$taskId")
|
||||
downloadManager.cancelDownload(taskId)
|
||||
downloadTasks.remove(taskId)
|
||||
downloadService?.stopDownload()
|
||||
|
||||
// Process next item in queue after cancellation
|
||||
processNextInQueue()
|
||||
}
|
||||
|
||||
Function("cancelQueuedDownload") { url: String ->
|
||||
// Remove from queue by URL
|
||||
downloadQueue.removeAll { queuedItem ->
|
||||
queuedItem.first == url
|
||||
}
|
||||
Log.d(TAG, "Removed queued download: $url, queue size: ${downloadQueue.size}")
|
||||
}
|
||||
|
||||
Function("cancelAllDownloads") {
|
||||
Log.d(TAG, "Cancelling all downloads")
|
||||
downloadManager.cancelAllDownloads()
|
||||
downloadTasks.clear()
|
||||
downloadQueue.clear()
|
||||
stopDownloadService()
|
||||
}
|
||||
|
||||
AsyncFunction("getActiveDownloads") { promise: Promise ->
|
||||
try {
|
||||
val activeDownloads = downloadTasks.map { (taskId, taskInfo) ->
|
||||
mapOf(
|
||||
"taskId" to taskId,
|
||||
"url" to taskInfo.url
|
||||
)
|
||||
}
|
||||
promise.resolve(activeDownloads)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDownloadInternal(urlString: String, destinationPath: String?): Int {
|
||||
val taskId = taskIdCounter++
|
||||
|
||||
if (destinationPath == null) {
|
||||
throw IllegalArgumentException("Destination path is required")
|
||||
}
|
||||
|
||||
downloadTasks[taskId] = DownloadTaskInfo(
|
||||
url = urlString,
|
||||
destinationPath = destinationPath
|
||||
)
|
||||
|
||||
// Start foreground service if not running
|
||||
startDownloadService()
|
||||
downloadService?.startDownload()
|
||||
|
||||
Log.d(TAG, "Starting download: taskId=$taskId, url=$urlString")
|
||||
|
||||
// Send started event
|
||||
sendEvent("onDownloadStarted", mapOf(
|
||||
"taskId" to taskId,
|
||||
"url" to urlString
|
||||
))
|
||||
|
||||
// Start the download with OkHttp
|
||||
downloadManager.startDownload(
|
||||
taskId = taskId,
|
||||
url = urlString,
|
||||
destinationPath = destinationPath,
|
||||
onProgress = { bytesWritten, totalBytes ->
|
||||
handleProgress(taskId, bytesWritten, totalBytes)
|
||||
},
|
||||
onComplete = { filePath ->
|
||||
handleDownloadComplete(taskId, filePath)
|
||||
},
|
||||
onError = { error ->
|
||||
handleError(taskId, error)
|
||||
}
|
||||
)
|
||||
|
||||
return taskId
|
||||
}
|
||||
|
||||
private fun processNextInQueue(): Int {
|
||||
// Check if queue has items
|
||||
if (downloadQueue.isEmpty()) {
|
||||
Log.d(TAG, "Queue is empty")
|
||||
return -1
|
||||
}
|
||||
|
||||
// Check if there are active downloads (one at a time)
|
||||
if (downloadTasks.isNotEmpty()) {
|
||||
Log.d(TAG, "Active downloads in progress (${downloadTasks.size}), waiting...")
|
||||
return -1
|
||||
}
|
||||
|
||||
// Get next item from queue
|
||||
val (url, destinationPath) = downloadQueue.removeAt(0)
|
||||
Log.d(TAG, "Processing next in queue: $url")
|
||||
|
||||
return try {
|
||||
startDownloadInternal(url, destinationPath)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error processing queue item: ${e.message}", e)
|
||||
// Try to process next item
|
||||
processNextInQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleProgress(taskId: Int, bytesWritten: Long, totalBytes: Long) {
|
||||
val progress = if (totalBytes > 0) {
|
||||
bytesWritten.toDouble() / totalBytes.toDouble()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
|
||||
// Update notification
|
||||
val taskInfo = downloadTasks[taskId]
|
||||
if (taskInfo != null) {
|
||||
val progressPercent = (progress * 100).toInt()
|
||||
downloadService?.updateProgress("Downloading video", progressPercent)
|
||||
}
|
||||
|
||||
sendEvent("onDownloadProgress", mapOf(
|
||||
"taskId" to taskId,
|
||||
"bytesWritten" to bytesWritten,
|
||||
"totalBytes" to totalBytes,
|
||||
"progress" to progress
|
||||
))
|
||||
}
|
||||
|
||||
private fun handleDownloadComplete(taskId: Int, filePath: String) {
|
||||
val taskInfo = downloadTasks[taskId]
|
||||
|
||||
if (taskInfo == null) {
|
||||
Log.e(TAG, "Download completed but task info not found: taskId=$taskId")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Download completed: taskId=$taskId, filePath=$filePath")
|
||||
|
||||
sendEvent("onDownloadComplete", mapOf(
|
||||
"taskId" to taskId,
|
||||
"filePath" to filePath,
|
||||
"url" to taskInfo.url
|
||||
))
|
||||
|
||||
downloadTasks.remove(taskId)
|
||||
downloadService?.stopDownload()
|
||||
|
||||
// Process next item in queue
|
||||
processNextInQueue()
|
||||
}
|
||||
|
||||
private fun handleError(taskId: Int, error: String) {
|
||||
val taskInfo = downloadTasks[taskId]
|
||||
|
||||
Log.e(TAG, "Download error: taskId=$taskId, error=$error")
|
||||
|
||||
sendEvent("onDownloadError", mapOf(
|
||||
"taskId" to taskId,
|
||||
"error" to error
|
||||
))
|
||||
|
||||
downloadTasks.remove(taskId)
|
||||
downloadService?.stopDownload()
|
||||
|
||||
// Process next item in queue even on error
|
||||
processNextInQueue()
|
||||
}
|
||||
|
||||
private fun startDownloadService() {
|
||||
if (!serviceBound) {
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopDownloadService() {
|
||||
if (serviceBound && downloadTasks.isEmpty()) {
|
||||
try {
|
||||
context.unbindService(serviceConnection)
|
||||
serviceBound = false
|
||||
downloadService = null
|
||||
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
context.stopService(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping service: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package expo.modules.backgrounddownloader
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class DownloadService : Service() {
|
||||
private val TAG = "DownloadService"
|
||||
private val NOTIFICATION_ID = 1001
|
||||
private val CHANNEL_ID = "download_channel"
|
||||
|
||||
private val binder = DownloadServiceBinder()
|
||||
private var activeDownloadCount = 0
|
||||
private var currentDownloadTitle = "Preparing download..."
|
||||
private var currentProgress = 0
|
||||
|
||||
inner class DownloadServiceBinder : Binder() {
|
||||
fun getService(): DownloadService = this@DownloadService
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "DownloadService created")
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
Log.d(TAG, "DownloadService bound")
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "DownloadService started")
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "DownloadService destroyed")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Downloads",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Video download progress"
|
||||
setShowBadge(false)
|
||||
}
|
||||
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(): Notification {
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(currentDownloadTitle)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
|
||||
if (currentProgress > 0) {
|
||||
builder.setProgress(100, currentProgress, false)
|
||||
.setContentText("$currentProgress% complete")
|
||||
} else {
|
||||
builder.setProgress(100, 0, true)
|
||||
.setContentText("Starting...")
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
fun startDownload() {
|
||||
activeDownloadCount++
|
||||
Log.d(TAG, "Download started, active count: $activeDownloadCount")
|
||||
if (activeDownloadCount == 1) {
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
}
|
||||
}
|
||||
|
||||
fun stopDownload() {
|
||||
activeDownloadCount = maxOf(0, activeDownloadCount - 1)
|
||||
Log.d(TAG, "Download stopped, active count: $activeDownloadCount")
|
||||
if (activeDownloadCount == 0) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProgress(title: String, progress: Int) {
|
||||
currentDownloadTitle = title
|
||||
currentProgress = progress
|
||||
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
notificationManager.notify(NOTIFICATION_ID, createNotification())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package expo.modules.backgrounddownloader
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class OkHttpDownloadManager {
|
||||
private val TAG = "OkHttpDownloadManager"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.callTimeout(0, TimeUnit.SECONDS) // No timeout for long transcodes
|
||||
.build()
|
||||
|
||||
private val activeDownloads = mutableMapOf<Int, Call>()
|
||||
|
||||
fun startDownload(
|
||||
taskId: Int,
|
||||
url: String,
|
||||
destinationPath: String,
|
||||
onProgress: (bytesWritten: Long, totalBytes: Long) -> Unit,
|
||||
onComplete: (filePath: String) -> Unit,
|
||||
onError: (error: String) -> Unit
|
||||
) {
|
||||
Log.d(TAG, "Starting download: taskId=$taskId, url=$url")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
val call = client.newCall(request)
|
||||
activeDownloads[taskId] = call
|
||||
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(TAG, "Download failed: taskId=$taskId, error=${e.message}")
|
||||
activeDownloads.remove(taskId)
|
||||
if (call.isCanceled()) {
|
||||
// Don't report cancellation as error
|
||||
return
|
||||
}
|
||||
onError(e.message ?: "Download failed")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
if (!response.isSuccessful) {
|
||||
Log.e(TAG, "Download failed with HTTP code: ${response.code}")
|
||||
activeDownloads.remove(taskId)
|
||||
onError("HTTP error: ${response.code} ${response.message}")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val totalBytes = response.body?.contentLength() ?: -1L
|
||||
val inputStream = response.body?.byteStream()
|
||||
|
||||
if (inputStream == null) {
|
||||
activeDownloads.remove(taskId)
|
||||
onError("Failed to get response body")
|
||||
return
|
||||
}
|
||||
|
||||
// Create destination directory if needed
|
||||
val destFile = File(destinationPath)
|
||||
val destDir = destFile.parentFile
|
||||
if (destDir != null && !destDir.exists()) {
|
||||
destDir.mkdirs()
|
||||
}
|
||||
|
||||
val outputStream = destFile.outputStream()
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesWritten = 0L
|
||||
var lastProgressUpdate = System.currentTimeMillis()
|
||||
|
||||
inputStream.use { input ->
|
||||
outputStream.use { output ->
|
||||
var bytes = input.read(buffer)
|
||||
while (bytes >= 0) {
|
||||
// Check if download was cancelled
|
||||
if (call.isCanceled()) {
|
||||
Log.d(TAG, "Download cancelled: taskId=$taskId")
|
||||
destFile.delete()
|
||||
activeDownloads.remove(taskId)
|
||||
return
|
||||
}
|
||||
|
||||
output.write(buffer, 0, bytes)
|
||||
bytesWritten += bytes
|
||||
|
||||
// Throttle progress updates to every 500ms
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastProgressUpdate >= 500) {
|
||||
onProgress(bytesWritten, totalBytes)
|
||||
lastProgressUpdate = now
|
||||
}
|
||||
|
||||
bytes = input.read(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send final progress update
|
||||
onProgress(bytesWritten, totalBytes)
|
||||
|
||||
Log.d(TAG, "Download completed: taskId=$taskId, bytes=$bytesWritten")
|
||||
activeDownloads.remove(taskId)
|
||||
onComplete(destinationPath)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during download: taskId=$taskId, error=${e.message}", e)
|
||||
activeDownloads.remove(taskId)
|
||||
|
||||
// Clean up partial file
|
||||
try {
|
||||
File(destinationPath).delete()
|
||||
} catch (deleteError: Exception) {
|
||||
Log.e(TAG, "Failed to delete partial file: ${deleteError.message}")
|
||||
}
|
||||
|
||||
if (!call.isCanceled()) {
|
||||
onError(e.message ?: "Download failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun cancelDownload(taskId: Int) {
|
||||
Log.d(TAG, "Cancelling download: taskId=$taskId")
|
||||
activeDownloads[taskId]?.cancel()
|
||||
activeDownloads.remove(taskId)
|
||||
}
|
||||
|
||||
fun cancelAllDownloads() {
|
||||
Log.d(TAG, "Cancelling all downloads")
|
||||
activeDownloads.values.forEach { it.cancel() }
|
||||
activeDownloads.clear()
|
||||
}
|
||||
|
||||
fun hasActiveDownloads(): Boolean {
|
||||
return activeDownloads.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
98
modules/background-downloader/example.ts
Normal file
98
modules/background-downloader/example.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
DownloadCompleteEvent,
|
||||
DownloadErrorEvent,
|
||||
DownloadProgressEvent,
|
||||
} from "@/modules";
|
||||
import { BackgroundDownloader } from "@/modules";
|
||||
|
||||
export class DownloadManager {
|
||||
private progressSubscription: any;
|
||||
private completeSubscription: any;
|
||||
private errorSubscription: any;
|
||||
private activeDownloads = new Map<
|
||||
number,
|
||||
{ url: string; progress: number }
|
||||
>();
|
||||
|
||||
constructor() {
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
private setupListeners() {
|
||||
this.progressSubscription = BackgroundDownloader.addProgressListener(
|
||||
(event: DownloadProgressEvent) => {
|
||||
const download = this.activeDownloads.get(event.taskId);
|
||||
if (download) {
|
||||
download.progress = event.progress;
|
||||
console.log(
|
||||
`Download ${event.taskId}: ${Math.floor(event.progress * 100)}%`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.completeSubscription = BackgroundDownloader.addCompleteListener(
|
||||
(event: DownloadCompleteEvent) => {
|
||||
console.log("Download complete:", event.filePath);
|
||||
this.activeDownloads.delete(event.taskId);
|
||||
},
|
||||
);
|
||||
|
||||
this.errorSubscription = BackgroundDownloader.addErrorListener(
|
||||
(event: DownloadErrorEvent) => {
|
||||
console.error("Download error:", event.error);
|
||||
this.activeDownloads.delete(event.taskId);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async startDownload(url: string, destinationPath?: string): Promise<number> {
|
||||
const taskId = await BackgroundDownloader.startDownload(
|
||||
url,
|
||||
destinationPath,
|
||||
);
|
||||
this.activeDownloads.set(taskId, { url, progress: 0 });
|
||||
return taskId;
|
||||
}
|
||||
|
||||
cancelDownload(taskId: number): void {
|
||||
BackgroundDownloader.cancelDownload(taskId);
|
||||
this.activeDownloads.delete(taskId);
|
||||
}
|
||||
|
||||
cancelAllDownloads(): void {
|
||||
BackgroundDownloader.cancelAllDownloads();
|
||||
this.activeDownloads.clear();
|
||||
}
|
||||
|
||||
async getActiveDownloads() {
|
||||
return await BackgroundDownloader.getActiveDownloads();
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.progressSubscription?.remove();
|
||||
this.completeSubscription?.remove();
|
||||
this.errorSubscription?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
const downloadManager = new DownloadManager();
|
||||
|
||||
export async function downloadFile(
|
||||
url: string,
|
||||
destinationPath?: string,
|
||||
): Promise<number> {
|
||||
return await downloadManager.startDownload(url, destinationPath);
|
||||
}
|
||||
|
||||
export function cancelDownload(taskId: number): void {
|
||||
downloadManager.cancelDownload(taskId);
|
||||
}
|
||||
|
||||
export function cancelAllDownloads(): void {
|
||||
downloadManager.cancelAllDownloads();
|
||||
}
|
||||
|
||||
export async function getActiveDownloads() {
|
||||
return await downloadManager.getActiveDownloads();
|
||||
}
|
||||
12
modules/background-downloader/expo-module.config.json
Normal file
12
modules/background-downloader/expo-module.config.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "background-downloader",
|
||||
"version": "1.0.0",
|
||||
"platforms": ["ios", "android"],
|
||||
"ios": {
|
||||
"modules": ["BackgroundDownloaderModule"],
|
||||
"appDelegateSubscribers": ["BackgroundDownloaderAppDelegate"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.backgrounddownloader.BackgroundDownloaderModule"]
|
||||
}
|
||||
}
|
||||
109
modules/background-downloader/index.ts
Normal file
109
modules/background-downloader/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { EventSubscription } from "expo-modules-core";
|
||||
import type {
|
||||
ActiveDownload,
|
||||
DownloadCompleteEvent,
|
||||
DownloadErrorEvent,
|
||||
DownloadProgressEvent,
|
||||
DownloadStartedEvent,
|
||||
} from "./src/BackgroundDownloader.types";
|
||||
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
|
||||
|
||||
export interface BackgroundDownloader {
|
||||
startDownload(url: string, destinationPath?: string): Promise<number>;
|
||||
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
|
||||
cancelDownload(taskId: number): void;
|
||||
cancelQueuedDownload(url: string): void;
|
||||
cancelAllDownloads(): void;
|
||||
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||
|
||||
addProgressListener(
|
||||
listener: (event: DownloadProgressEvent) => void,
|
||||
): EventSubscription;
|
||||
|
||||
addCompleteListener(
|
||||
listener: (event: DownloadCompleteEvent) => void,
|
||||
): EventSubscription;
|
||||
|
||||
addErrorListener(
|
||||
listener: (event: DownloadErrorEvent) => void,
|
||||
): EventSubscription;
|
||||
|
||||
addStartedListener(
|
||||
listener: (event: DownloadStartedEvent) => void,
|
||||
): EventSubscription;
|
||||
}
|
||||
|
||||
const BackgroundDownloader: BackgroundDownloader = {
|
||||
async startDownload(url: string, destinationPath?: string): Promise<number> {
|
||||
return await BackgroundDownloaderModule.startDownload(url, destinationPath);
|
||||
},
|
||||
|
||||
async enqueueDownload(
|
||||
url: string,
|
||||
destinationPath?: string,
|
||||
): Promise<number> {
|
||||
return await BackgroundDownloaderModule.enqueueDownload(
|
||||
url,
|
||||
destinationPath,
|
||||
);
|
||||
},
|
||||
|
||||
cancelDownload(taskId: number): void {
|
||||
BackgroundDownloaderModule.cancelDownload(taskId);
|
||||
},
|
||||
|
||||
cancelQueuedDownload(url: string): void {
|
||||
BackgroundDownloaderModule.cancelQueuedDownload(url);
|
||||
},
|
||||
|
||||
cancelAllDownloads(): void {
|
||||
BackgroundDownloaderModule.cancelAllDownloads();
|
||||
},
|
||||
|
||||
async getActiveDownloads(): Promise<ActiveDownload[]> {
|
||||
return await BackgroundDownloaderModule.getActiveDownloads();
|
||||
},
|
||||
|
||||
addProgressListener(
|
||||
listener: (event: DownloadProgressEvent) => void,
|
||||
): EventSubscription {
|
||||
return BackgroundDownloaderModule.addListener(
|
||||
"onDownloadProgress",
|
||||
listener,
|
||||
);
|
||||
},
|
||||
|
||||
addCompleteListener(
|
||||
listener: (event: DownloadCompleteEvent) => void,
|
||||
): EventSubscription {
|
||||
return BackgroundDownloaderModule.addListener(
|
||||
"onDownloadComplete",
|
||||
listener,
|
||||
);
|
||||
},
|
||||
|
||||
addErrorListener(
|
||||
listener: (event: DownloadErrorEvent) => void,
|
||||
): EventSubscription {
|
||||
return BackgroundDownloaderModule.addListener("onDownloadError", listener);
|
||||
},
|
||||
|
||||
addStartedListener(
|
||||
listener: (event: DownloadStartedEvent) => void,
|
||||
): EventSubscription {
|
||||
return BackgroundDownloaderModule.addListener(
|
||||
"onDownloadStarted",
|
||||
listener,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default BackgroundDownloader;
|
||||
|
||||
export type {
|
||||
ActiveDownload,
|
||||
DownloadCompleteEvent,
|
||||
DownloadErrorEvent,
|
||||
DownloadProgressEvent,
|
||||
DownloadStartedEvent,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'BackgroundDownloader'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Background file downloader for iOS'
|
||||
s.description = 'Native iOS module for downloading large files in the background using NSURLSession'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = { :ios => '15.6', :tvos => '15.0' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import ExpoModulesCore
|
||||
import UIKit
|
||||
|
||||
public class BackgroundDownloaderAppDelegate: ExpoAppDelegateSubscriber {
|
||||
public func application(
|
||||
_ application: UIApplication,
|
||||
handleEventsForBackgroundURLSession identifier: String,
|
||||
completionHandler: @escaping () -> Void
|
||||
) {
|
||||
if identifier == "com.fredrikburmester.streamyfin.backgrounddownloader" {
|
||||
BackgroundDownloaderModule.setBackgroundCompletionHandler(completionHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import ExpoModulesCore
|
||||
import Foundation
|
||||
|
||||
enum DownloadError: Error {
|
||||
case invalidURL
|
||||
case fileOperationFailed
|
||||
case downloadFailed
|
||||
}
|
||||
|
||||
struct DownloadTaskInfo {
|
||||
let url: String
|
||||
let destinationPath: String?
|
||||
}
|
||||
|
||||
// Separate delegate class to handle URLSession callbacks
|
||||
class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate {
|
||||
weak var module: BackgroundDownloaderModule?
|
||||
|
||||
init(module: BackgroundDownloaderModule) {
|
||||
self.module = module
|
||||
super.init()
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didWriteData bytesWritten: Int64,
|
||||
totalBytesWritten: Int64,
|
||||
totalBytesExpectedToWrite: Int64
|
||||
) {
|
||||
module?.handleProgress(
|
||||
taskId: downloadTask.taskIdentifier,
|
||||
bytesWritten: totalBytesWritten,
|
||||
totalBytes: totalBytesExpectedToWrite
|
||||
)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL
|
||||
) {
|
||||
module?.handleDownloadComplete(
|
||||
taskId: downloadTask.taskIdentifier,
|
||||
location: location,
|
||||
downloadTask: downloadTask
|
||||
)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didCompleteWithError error: Error?
|
||||
) {
|
||||
if let error = error {
|
||||
print("[BackgroundDownloader] Task \(task.taskIdentifier) error: \(error.localizedDescription)")
|
||||
module?.handleError(taskId: task.taskIdentifier, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||
DispatchQueue.main.async {
|
||||
if let completion = BackgroundDownloaderModule.backgroundCompletionHandler {
|
||||
completion()
|
||||
BackgroundDownloaderModule.backgroundCompletionHandler = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class BackgroundDownloaderModule: Module {
|
||||
private var session: URLSession?
|
||||
private var sessionDelegate: DownloadSessionDelegate?
|
||||
fileprivate static var backgroundCompletionHandler: (() -> Void)?
|
||||
private var downloadTasks: [Int: DownloadTaskInfo] = [:]
|
||||
private var downloadQueue: [(url: String, destinationPath: String?)] = []
|
||||
private var lastProgressTime: [Int: Date] = [:]
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("BackgroundDownloader")
|
||||
|
||||
Events(
|
||||
"onDownloadProgress",
|
||||
"onDownloadComplete",
|
||||
"onDownloadError",
|
||||
"onDownloadStarted"
|
||||
)
|
||||
|
||||
OnCreate {
|
||||
self.initializeSession()
|
||||
}
|
||||
|
||||
AsyncFunction("startDownload") { (urlString: String, destinationPath: String?) -> Int in
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw DownloadError.invalidURL
|
||||
}
|
||||
|
||||
if self.session == nil {
|
||||
self.initializeSession()
|
||||
}
|
||||
|
||||
guard let session = self.session else {
|
||||
throw DownloadError.downloadFailed
|
||||
}
|
||||
|
||||
// Create a URLRequest to ensure proper handling
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = 300
|
||||
|
||||
let task = session.downloadTask(with: request)
|
||||
let taskId = task.taskIdentifier
|
||||
|
||||
self.downloadTasks[taskId] = DownloadTaskInfo(
|
||||
url: urlString,
|
||||
destinationPath: destinationPath
|
||||
)
|
||||
|
||||
task.resume()
|
||||
|
||||
self.sendEvent("onDownloadStarted", [
|
||||
"taskId": taskId,
|
||||
"url": urlString
|
||||
])
|
||||
|
||||
return taskId
|
||||
}
|
||||
|
||||
AsyncFunction("enqueueDownload") { (urlString: String, destinationPath: String?) -> Int in
|
||||
// Add to queue
|
||||
let wasEmpty = self.downloadQueue.isEmpty
|
||||
self.downloadQueue.append((url: urlString, destinationPath: destinationPath))
|
||||
|
||||
// If queue was empty and no active downloads, start processing immediately
|
||||
if wasEmpty {
|
||||
return try await self.processNextInQueue()
|
||||
}
|
||||
|
||||
// Return placeholder taskId for queued items
|
||||
return -1
|
||||
}
|
||||
|
||||
Function("cancelDownload") { (taskId: Int) in
|
||||
self.session?.getAllTasks { tasks in
|
||||
for task in tasks where task.taskIdentifier == taskId {
|
||||
task.cancel()
|
||||
self.downloadTasks.removeValue(forKey: taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Function("cancelQueuedDownload") { (url: String) in
|
||||
// Remove from queue by URL
|
||||
self.downloadQueue.removeAll { queuedItem in
|
||||
queuedItem.url == url
|
||||
}
|
||||
}
|
||||
|
||||
Function("cancelAllDownloads") {
|
||||
self.session?.getAllTasks { tasks in
|
||||
for task in tasks {
|
||||
task.cancel()
|
||||
}
|
||||
self.downloadTasks.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("getActiveDownloads") { () -> [[String: Any]] in
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let downloadTasks = self.downloadTasks
|
||||
|
||||
self.session?.getAllTasks { tasks in
|
||||
let activeDownloads = tasks.compactMap { task -> [String: Any]? in
|
||||
guard task is URLSessionDownloadTask,
|
||||
let info = downloadTasks[task.taskIdentifier] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return [
|
||||
"taskId": task.taskIdentifier,
|
||||
"url": info.url
|
||||
]
|
||||
}
|
||||
continuation.resume(returning: activeDownloads)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func initializeSession() {
|
||||
print("[BackgroundDownloader] Initializing URLSession")
|
||||
|
||||
let config = URLSessionConfiguration.background(
|
||||
withIdentifier: "com.fredrikburmester.streamyfin.backgrounddownloader"
|
||||
)
|
||||
config.allowsCellularAccess = true
|
||||
config.sessionSendsLaunchEvents = true
|
||||
config.isDiscretionary = false
|
||||
|
||||
self.sessionDelegate = DownloadSessionDelegate(module: self)
|
||||
self.session = URLSession(
|
||||
configuration: config,
|
||||
delegate: self.sessionDelegate,
|
||||
delegateQueue: nil
|
||||
)
|
||||
|
||||
print("[BackgroundDownloader] URLSession initialized with delegate: \(String(describing: self.sessionDelegate))")
|
||||
print("[BackgroundDownloader] Session identifier: \(config.identifier ?? "nil")")
|
||||
print("[BackgroundDownloader] Delegate queue: nil (uses default)")
|
||||
|
||||
// Verify delegate is connected
|
||||
if let session = self.session, session.delegate != nil {
|
||||
print("[BackgroundDownloader] ✅ Delegate successfully attached to session")
|
||||
} else {
|
||||
print("[BackgroundDownloader] ⚠️ DELEGATE NOT ATTACHED!")
|
||||
}
|
||||
}
|
||||
|
||||
// Handler methods called by the delegate
|
||||
func handleProgress(taskId: Int, bytesWritten: Int64, totalBytes: Int64) {
|
||||
let progress = totalBytes > 0
|
||||
? Double(bytesWritten) / Double(totalBytes)
|
||||
: 0.0
|
||||
|
||||
// Throttle progress updates: only send every 500ms
|
||||
let lastTime = lastProgressTime[taskId] ?? Date.distantPast
|
||||
let now = Date()
|
||||
let timeDiff = now.timeIntervalSince(lastTime)
|
||||
|
||||
// Send if 500ms passed
|
||||
if timeDiff >= 0.5 {
|
||||
self.sendEvent("onDownloadProgress", [
|
||||
"taskId": taskId,
|
||||
"bytesWritten": bytesWritten,
|
||||
"totalBytes": totalBytes,
|
||||
"progress": progress
|
||||
])
|
||||
|
||||
lastProgressTime[taskId] = now
|
||||
}
|
||||
}
|
||||
|
||||
func handleDownloadComplete(taskId: Int, location: URL, downloadTask: URLSessionDownloadTask) {
|
||||
guard let taskInfo = downloadTasks[taskId] else {
|
||||
self.sendEvent("onDownloadError", [
|
||||
"taskId": taskId,
|
||||
"error": "Download task info not found"
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
|
||||
do {
|
||||
let destinationURL: URL
|
||||
|
||||
if let customPath = taskInfo.destinationPath {
|
||||
destinationURL = URL(fileURLWithPath: customPath)
|
||||
} else {
|
||||
let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
let filename = downloadTask.response?.suggestedFilename
|
||||
?? downloadTask.originalRequest?.url?.lastPathComponent
|
||||
?? "download_\(taskId)"
|
||||
destinationURL = documentsDir.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||
try fileManager.removeItem(at: destinationURL)
|
||||
}
|
||||
|
||||
let destinationDirectory = destinationURL.deletingLastPathComponent()
|
||||
if !fileManager.fileExists(atPath: destinationDirectory.path) {
|
||||
try fileManager.createDirectory(
|
||||
at: destinationDirectory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
}
|
||||
|
||||
try fileManager.moveItem(at: location, to: destinationURL)
|
||||
|
||||
self.sendEvent("onDownloadComplete", [
|
||||
"taskId": taskId,
|
||||
"filePath": destinationURL.path,
|
||||
"url": taskInfo.url
|
||||
])
|
||||
|
||||
downloadTasks.removeValue(forKey: taskId)
|
||||
lastProgressTime.removeValue(forKey: taskId)
|
||||
|
||||
// Process next item in queue
|
||||
Task {
|
||||
do {
|
||||
_ = try await self.processNextInQueue()
|
||||
} catch {
|
||||
print("[BackgroundDownloader] Error processing next: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
self.sendEvent("onDownloadError", [
|
||||
"taskId": taskId,
|
||||
"error": "File operation failed: \(error.localizedDescription)"
|
||||
])
|
||||
|
||||
// Process next item in queue even on error
|
||||
Task {
|
||||
do {
|
||||
_ = try await self.processNextInQueue()
|
||||
} catch {
|
||||
print("[BackgroundDownloader] Error processing next: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleError(taskId: Int, error: Error) {
|
||||
let isCancelled = (error as NSError).code == NSURLErrorCancelled
|
||||
|
||||
if !isCancelled {
|
||||
print("[BackgroundDownloader] Task \(taskId) error: \(error.localizedDescription)")
|
||||
|
||||
self.sendEvent("onDownloadError", [
|
||||
"taskId": taskId,
|
||||
"error": error.localizedDescription
|
||||
])
|
||||
}
|
||||
|
||||
downloadTasks.removeValue(forKey: taskId)
|
||||
lastProgressTime.removeValue(forKey: taskId)
|
||||
|
||||
// Process next item in queue (whether cancelled or errored)
|
||||
Task {
|
||||
do {
|
||||
_ = try await self.processNextInQueue()
|
||||
} catch {
|
||||
print("[BackgroundDownloader] Error processing next: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processNextInQueue() async throws -> Int {
|
||||
// Check if queue has items
|
||||
guard !downloadQueue.isEmpty else {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Check if there are active downloads
|
||||
if !downloadTasks.isEmpty {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Get next item from queue
|
||||
let (url, destinationPath) = downloadQueue.removeFirst()
|
||||
print("[BackgroundDownloader] Starting queued download")
|
||||
|
||||
// Start the download using existing startDownload logic
|
||||
guard let urlObj = URL(string: url) else {
|
||||
print("[BackgroundDownloader] Invalid URL in queue: \(url)")
|
||||
return try await processNextInQueue()
|
||||
}
|
||||
|
||||
if session == nil {
|
||||
initializeSession()
|
||||
}
|
||||
|
||||
guard let session = self.session else {
|
||||
throw DownloadError.downloadFailed
|
||||
}
|
||||
|
||||
var request = URLRequest(url: urlObj)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = 300
|
||||
|
||||
let task = session.downloadTask(with: request)
|
||||
let taskId = task.taskIdentifier
|
||||
|
||||
downloadTasks[taskId] = DownloadTaskInfo(
|
||||
url: url,
|
||||
destinationPath: destinationPath
|
||||
)
|
||||
|
||||
task.resume()
|
||||
|
||||
sendEvent("onDownloadStarted", [
|
||||
"taskId": taskId,
|
||||
"url": url
|
||||
])
|
||||
|
||||
return taskId
|
||||
}
|
||||
|
||||
static func setBackgroundCompletionHandler(_ handler: @escaping () -> Void) {
|
||||
BackgroundDownloaderModule.backgroundCompletionHandler = handler
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { EventSubscription } from "expo-modules-core";
|
||||
|
||||
export interface DownloadProgressEvent {
|
||||
taskId: number;
|
||||
bytesWritten: number;
|
||||
totalBytes: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface DownloadCompleteEvent {
|
||||
taskId: number;
|
||||
filePath: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface DownloadErrorEvent {
|
||||
taskId: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface DownloadStartedEvent {
|
||||
taskId: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ActiveDownload {
|
||||
taskId: number;
|
||||
url: string;
|
||||
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
|
||||
}
|
||||
|
||||
export interface BackgroundDownloaderModuleType {
|
||||
startDownload(url: string, destinationPath?: string): Promise<number>;
|
||||
cancelDownload(taskId: number): void;
|
||||
cancelAllDownloads(): void;
|
||||
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||
addListener(
|
||||
eventName: string,
|
||||
listener: (event: any) => void,
|
||||
): EventSubscription;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { BackgroundDownloaderModuleType } from "./BackgroundDownloader.types";
|
||||
|
||||
const BackgroundDownloaderModule: BackgroundDownloaderModuleType =
|
||||
requireNativeModule("BackgroundDownloader");
|
||||
|
||||
export default BackgroundDownloaderModule;
|
||||
@@ -12,6 +12,16 @@ import type {
|
||||
} from "./VlcPlayer.types";
|
||||
import VlcPlayerView from "./VlcPlayerView";
|
||||
|
||||
export type {
|
||||
ActiveDownload,
|
||||
DownloadCompleteEvent,
|
||||
DownloadErrorEvent,
|
||||
DownloadProgressEvent,
|
||||
DownloadStartedEvent,
|
||||
} from "./background-downloader";
|
||||
// Background Downloader
|
||||
export { default as BackgroundDownloader } from "./background-downloader";
|
||||
|
||||
// Component
|
||||
export { VlcPlayerView };
|
||||
|
||||
|
||||
@@ -212,9 +212,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
fun setSource(source: Map<String, Any>) {
|
||||
log.debug("setting source $source")
|
||||
if (hasSource) {
|
||||
log.debug("Source already set. Resuming")
|
||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||
play()
|
||||
log.debug("Source already set. Ignoring.")
|
||||
return
|
||||
}
|
||||
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
|
||||
|
||||
Reference in New Issue
Block a user