feat: background task for mp4 downloads

This commit is contained in:
Adrian Castro
2024-03-27 23:57:02 +01:00
parent 1c5a63f8f1
commit 57cd3e642b
5 changed files with 114 additions and 58 deletions

View File

@@ -1,8 +1,15 @@
import type { DownloadTask } from "@kesha-antonov/react-native-background-downloader";
import type { Asset } from "expo-media-library";
import type { ReactNode } from "react";
import React, { createContext, useContext, useEffect, useState } from "react";
import * as FileSystem from "expo-file-system";
import * as MediaLibrary from "expo-media-library";
import {
checkForExistingDownloads,
completeHandler,
download,
setConfig,
} from "@kesha-antonov/react-native-background-downloader";
import VideoManager from "@salihgun/react-native-video-processor";
import { useToastController } from "@tamagui/toast";
@@ -25,9 +32,15 @@ export interface DownloadItem {
asset?: Asset;
isHLS?: boolean;
media: ScrapeMedia;
downloadResumable?: FileSystem.DownloadResumable;
downloadTask?: DownloadTask;
}
// @ts-expect-error - types are not up to date
setConfig({
isLogsEnabled: false,
progressInterval: 250,
});
interface DownloadManagerContextType {
downloads: DownloadItem[];
startDownload: (
@@ -37,7 +50,7 @@ interface DownloadManagerContextType {
headers?: Record<string, string>,
) => Promise<Asset | void>;
removeDownload: (id: string) => void;
cancelDownload: (id: string) => Promise<void>;
cancelDownload: (id: string) => void;
}
const DownloadManagerContext = createContext<
@@ -85,11 +98,11 @@ export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
return cancellationFlags[downloadId] ?? false;
};
const cancelDownload = async (downloadId: string): Promise<void> => {
const cancelDownload = (downloadId: string) => {
setCancellationFlag(downloadId, true);
const downloadItem = downloads.find((d) => d.id === downloadId);
if (downloadItem?.downloadResumable) {
await downloadItem.downloadResumable.cancelAsync();
if (downloadItem?.downloadTask) {
downloadItem.downloadTask.stop();
}
toastController.show("Download cancelled", {
burntOptions: { preset: "done" },
@@ -98,6 +111,35 @@ export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
});
};
// const initializeDownloader = async () => {
// setConfig({ isLogsEnabled: true }); // Set any global configs here
// const existingTasks = await checkForExistingDownloads();
// existingTasks.forEach(task => {
// // Reattach event listeners to existing tasks
// processTask(task);
// });
// };
const _processTask = (task: DownloadTask) => {
task
.progress(({ bytesDownloaded, bytesTotal }) => {
const progress = bytesDownloaded / bytesTotal;
updateDownloadItem(task.id, { progress });
})
.done(() => {
completeHandler(task.id);
})
.error(({ error, errorCode }) => {
console.error(`Download error: ${errorCode} - ${error}`);
});
const downloadItem = downloads.find((d) => d.id === task.id);
if (downloadItem) {
updateDownloadItem(task.id, { downloadTask: task });
}
};
const startDownload = async (
url: string,
type: "mp4" | "hls",
@@ -143,66 +185,80 @@ export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
);
};
const downloadMP4 = async (
interface DownloadProgress {
bytesDownloaded: number;
bytesTotal: number;
}
const downloadMP4 = (
url: string,
downloadId: string,
headers: Record<string, string>,
) => {
let lastBytesWritten = 0;
let lastTimestamp = Date.now();
): Promise<Asset> => {
return new Promise<Asset>((resolve, reject) => {
let lastBytesWritten = 0;
let lastTimestamp = Date.now();
const callback = (downloadProgress: FileSystem.DownloadProgressData) => {
const currentTime = Date.now();
const timeElapsed = (currentTime - lastTimestamp) / 1000;
const updateProgress = (downloadProgress: DownloadProgress) => {
const currentTime = Date.now();
const timeElapsed = (currentTime - lastTimestamp) / 1000;
if (timeElapsed === 0) return;
if (timeElapsed === 0) return;
const bytesWritten = downloadProgress.totalBytesWritten;
const newBytes = bytesWritten - lastBytesWritten;
const speed = newBytes / timeElapsed / 1024;
const progress =
bytesWritten / downloadProgress.totalBytesExpectedToWrite;
const newBytes = downloadProgress.bytesDownloaded - lastBytesWritten;
const speed = newBytes / timeElapsed / 1024;
const progress =
downloadProgress.bytesDownloaded / downloadProgress.bytesTotal;
updateDownloadItem(downloadId, {
progress,
speed,
fileSize: downloadProgress.totalBytesExpectedToWrite,
downloaded: bytesWritten,
});
updateDownloadItem(downloadId, {
progress,
speed,
fileSize: downloadProgress.bytesTotal,
downloaded: downloadProgress.bytesDownloaded,
});
lastBytesWritten = bytesWritten;
lastTimestamp = currentTime;
};
lastBytesWritten = downloadProgress.bytesDownloaded;
lastTimestamp = currentTime;
};
const fileUri = FileSystem.cacheDirectory
? FileSystem.cacheDirectory + url.split("/").pop()
: null;
if (!fileUri) {
console.error("Cache directory is unavailable");
return;
}
const downloadResumable = FileSystem.createDownloadResumable(
url,
fileUri,
{ headers },
callback,
);
updateDownloadItem(downloadId, { downloadResumable });
try {
const result = await downloadResumable.downloadAsync();
if (result) {
console.log("Finished downloading to ", result.uri);
const asset = await saveFileToMediaLibraryAndDeleteOriginal(
result.uri,
downloadId,
);
return asset;
const fileUri = FileSystem.cacheDirectory
? FileSystem.cacheDirectory + url.split("/").pop()
: null;
if (!fileUri) {
console.error("Cache directory is unavailable");
reject(new Error("Cache directory is unavailable"));
return;
}
} catch (e) {
console.error(e);
}
const downloadTask = download({
id: downloadId,
url,
destination: fileUri,
headers,
isNotificationVisible: true,
})
.begin(() => {
updateDownloadItem(downloadId, { downloadTask });
})
.progress(({ bytesDownloaded, bytesTotal }) => {
updateProgress({ bytesDownloaded, bytesTotal });
})
.done(() => {
saveFileToMediaLibraryAndDeleteOriginal(fileUri, downloadId)
.then((asset) => {
if (asset) {
resolve(asset);
} else {
reject(new Error("No asset returned"));
}
})
.catch((error) => reject(error));
})
.error(({ error, errorCode }) => {
console.error(`Download error: ${errorCode} - ${error}`);
reject(new Error(`Download error: ${errorCode} - ${error}`));
});
});
};
const downloadHLS = async (

View File

@@ -0,0 +1,48 @@
const { withAppDelegate } = require("@expo/config-plugins");
function withRNBackgroundDownloader(expoConfig) {
return withAppDelegate(expoConfig, async (appDelegateConfig) => {
const { modResults: appDelegate } = appDelegateConfig;
const appDelegateLines = appDelegate.contents.split("\n");
// Define the code to be added to AppDelegate.mm
const backgroundDownloaderImport =
"#import <RNBackgroundDownloader.h> // Required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js";
const backgroundDownloaderDelegate = `\n// Delegate method required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler
{
[RNBackgroundDownloader setCompletionHandlerWithIdentifier:identifier completionHandler:completionHandler];
}`;
// Find the index of the AppDelegate import statement
const importIndex = appDelegateLines.findIndex((line) =>
/^#import "AppDelegate.h"/.test(line),
);
// Find the index of the last line before the @end statement
const endStatementIndex = appDelegateLines.findIndex((line) =>
/@end/.test(line),
);
// Insert the import statement if it's not already present
if (!appDelegate.contents.includes(backgroundDownloaderImport)) {
appDelegateLines.splice(importIndex + 1, 0, backgroundDownloaderImport);
}
// Insert the delegate method above the @end statement
if (!appDelegate.contents.includes(backgroundDownloaderDelegate)) {
appDelegateLines.splice(
endStatementIndex,
0,
backgroundDownloaderDelegate,
);
}
// Update the contents of the AppDelegate file
appDelegate.contents = appDelegateLines.join("\n");
return appDelegateConfig;
});
}
module.exports = withRNBackgroundDownloader;

View File

@@ -0,0 +1,11 @@
const withEntitlementsPlist =
require("@expo/config-plugins").withEntitlementsPlist;
const withRemoveiOSNotificationEntitlement = (config) => {
return withEntitlementsPlist(config, (mod) => {
delete mod.modResults["aps-environment"];
return mod;
});
};
module.exports = withRemoveiOSNotificationEntitlement;