diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index 2ec6874..74d7570 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -2,7 +2,6 @@ import type { ExpoConfig } from "expo/config"; import { version } from "./package.json"; import withRemoveiOSNotificationEntitlement from "./src/plugins/withRemoveiOSNotificationEntitlement"; -import withRNBackgroundDownloader from "./src/plugins/withRNBackgroundDownloader"; const defineConfig = (): ExpoConfig => ({ name: "movie-web", @@ -48,7 +47,6 @@ const defineConfig = (): ExpoConfig => ({ plugins: [ "expo-router", [withRemoveiOSNotificationEntitlement as unknown as string], - [withRNBackgroundDownloader as unknown as string], [ "expo-screen-orientation", { diff --git a/apps/expo/package.json b/apps/expo/package.json index be5b9c8..cad33e4 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -19,7 +19,6 @@ }, "dependencies": { "@expo/metro-config": "^0.17.3", - "@kesha-antonov/react-native-background-downloader": "^3.1.2", "@movie-web/api": "*", "@movie-web/colors": "*", "@movie-web/provider-utils": "*", diff --git a/apps/expo/src/hooks/useDownloadManager.tsx b/apps/expo/src/hooks/useDownloadManager.tsx index b21e4bd..61cf8e6 100644 --- a/apps/expo/src/hooks/useDownloadManager.tsx +++ b/apps/expo/src/hooks/useDownloadManager.tsx @@ -1,16 +1,10 @@ -import type { DownloadTask } from "@kesha-antonov/react-native-background-downloader"; +import type { DownloadProgressData } from "expo-file-system"; import type { Asset } from "expo-media-library"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import * as FileSystem from "expo-file-system"; import * as MediaLibrary from "expo-media-library"; import * as Network from "expo-network"; import { NetworkStateType } from "expo-network"; -import { - checkForExistingDownloads, - completeHandler, - download, - setConfig, -} from "@kesha-antonov/react-native-background-downloader"; import VideoManager from "@salihgun/react-native-video-processor"; import type { ScrapeMedia } from "@movie-web/provider-utils"; @@ -22,11 +16,6 @@ import { useNetworkSettingsStore, } from "~/stores/settings"; -interface DownloadProgress { - bytesDownloaded: number; - bytesTotal: number; -} - export interface Download { id: string; progress: number; @@ -44,7 +33,7 @@ export interface Download { | "importing"; localPath?: string; media: ScrapeMedia; - downloadTask?: DownloadTask; + downloadTask?: FileSystem.DownloadResumable; } export interface DownloadContent { @@ -52,12 +41,6 @@ export interface DownloadContent { downloads: Download[]; } -// @ts-expect-error - types are not up to date -setConfig({ - isLogsEnabled: false, - progressInterval: 250, -}); - export const useDownloadManager = () => { const cancellationFlags = useState>({})[0]; @@ -76,10 +59,10 @@ export const useDownloadManager = () => { [cancellationFlags], ); - const cancelDownload = (download: Download) => { + const cancelDownload = async (download: Download) => { setCancellationFlag(download.id, true); if (download?.downloadTask) { - download.downloadTask.stop(); + await download.downloadTask.cancelAsync(); } showToast("Download cancelled", { burntOptions: { preset: "done" }, @@ -141,112 +124,97 @@ export const useDownloadManager = () => { [setDownloads], ); - const saveFileToMediaLibraryAndDeleteOriginal = useCallback( - async (fileUri: string, download: Download): Promise => { - console.log( - "Saving file to media library and deleting original", - fileUri, - ); - try { - updateDownloadItem(download.id, { status: "importing" }); + const saveFileToMediaLibraryAndDeleteOriginal = async ( + fileUri: string, + download: Download, + ): Promise => { + console.log("Saving file to media library and deleting original", fileUri); + try { + updateDownloadItem(download.id, { status: "importing" }); - const asset = await MediaLibrary.createAssetAsync(fileUri); - const { localUri } = await MediaLibrary.getAssetInfoAsync(asset); - await FileSystem.deleteAsync(fileUri); + const asset = await MediaLibrary.createAssetAsync(fileUri); + const { localUri } = await MediaLibrary.getAssetInfoAsync(asset); + await FileSystem.deleteAsync(fileUri); - updateDownloadItem(download.id, { - status: "finished", - localPath: localUri, - }); - console.log("File saved to media library and original deleted"); - showToast("Download finished", { - burntOptions: { preset: "done" }, - }); - return asset; - } catch (error) { - console.error("Error saving file to media library:", error); - showToast("Download failed", { - burntOptions: { preset: "error" }, - }); - } - }, - [updateDownloadItem, showToast], - ); - - const downloadMP4 = useCallback( - ( - url: string, - downloadItem: Download, - headers: Record, - ): Promise => { - return new Promise((resolve, reject) => { - let lastBytesWritten = 0; - let lastTimestamp = Date.now(); - - const updateProgress = (downloadProgress: DownloadProgress) => { - const currentTime = Date.now(); - const timeElapsed = (currentTime - lastTimestamp) / 1000; - - if (timeElapsed === 0) return; - - const newBytes = downloadProgress.bytesDownloaded - lastBytesWritten; - const speed = newBytes / timeElapsed / 1024 / 1024; - const progress = - downloadProgress.bytesDownloaded / downloadProgress.bytesTotal; - - updateDownloadItem(downloadItem.id, { - progress, - speed, - fileSize: downloadProgress.bytesTotal, - downloaded: downloadProgress.bytesDownloaded, - }); - - lastBytesWritten = downloadProgress.bytesDownloaded; - lastTimestamp = currentTime; - }; - - const fileUri = - FileSystem.cacheDirectory + "movie-web" - ? FileSystem.cacheDirectory + "movie-web" + url.split("/").pop() - : null; - if (!fileUri) { - console.error("Cache directory is unavailable"); - reject(new Error("Cache directory is unavailable")); - return; - } - - const downloadTask = download({ - id: downloadItem.id, - url, - destination: fileUri, - headers, - isNotificationVisible: true, - }) - .begin(() => { - updateDownloadItem(downloadItem.id, { downloadTask }); - }) - .progress(({ bytesDownloaded, bytesTotal }) => { - updateProgress({ bytesDownloaded, bytesTotal }); - }) - .done(() => { - saveFileToMediaLibraryAndDeleteOriginal(fileUri, downloadItem) - .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}`)); - }); + updateDownloadItem(download.id, { + status: "finished", + localPath: localUri, }); - }, - [saveFileToMediaLibraryAndDeleteOriginal, updateDownloadItem], - ); + console.log("File saved to media library and original deleted"); + showToast("Download finished", { + burntOptions: { preset: "done" }, + }); + return asset; + } catch (error) { + console.error("Error saving file to media library:", error); + showToast("Download failed", { + burntOptions: { preset: "error" }, + }); + } + }; + + const downloadMP4 = async ( + url: string, + downloadItem: Download, + headers: Record, + ): Promise => { + let lastBytesWritten = 0; + let lastTimestamp = Date.now(); + + const updateProgress = (downloadProgress: DownloadProgressData) => { + const currentTime = Date.now(); + const timeElapsed = (currentTime - lastTimestamp) / 1000; + + if (timeElapsed === 0) return; + + const newBytes = downloadProgress.totalBytesWritten - lastBytesWritten; + const speed = newBytes / timeElapsed / 1024 / 1024; + const progress = + downloadProgress.totalBytesWritten / + downloadProgress.totalBytesExpectedToWrite; + + updateDownloadItem(downloadItem.id, { + progress, + speed, + fileSize: downloadProgress.totalBytesExpectedToWrite, + downloaded: downloadProgress.totalBytesWritten, + }); + + lastBytesWritten = downloadProgress.totalBytesWritten; + lastTimestamp = currentTime; + }; + + const fileUri = + FileSystem.cacheDirectory + "movie-web" + ? FileSystem.cacheDirectory + "movie-web" + url.split("/").pop() + : null; + if (!fileUri) { + console.error("Cache directory is unavailable"); + return; + } + + const downloadResumable = FileSystem.createDownloadResumable( + url, + fileUri, + { + headers, + }, + updateProgress, + ); + + try { + const result = await downloadResumable.downloadAsync(); + if (result) { + console.log("Finished downloading to ", result.uri); + return saveFileToMediaLibraryAndDeleteOriginal( + result.uri, + downloadItem, + ); + } + } catch (e) { + console.error(e); + } + }; const cleanupDownload = useCallback( async (segmentDir: string, download: Download) => { @@ -256,201 +224,184 @@ export const useDownloadManager = () => { [removeDownload], ); - const downloadHLS = useCallback( - async ( - url: string, - download: Download, - headers: Record, - ) => { - const segments = await extractSegmentsFromHLS(url, headers); + const downloadHLS = async ( + url: string, + download: Download, + headers: Record, + ) => { + const segments = await extractSegmentsFromHLS(url, headers); - if (!segments || segments.length === 0) { - return removeDownload(download); + if (!segments || segments.length === 0) { + return removeDownload(download); + } + + const totalSegments = segments.length; + let segmentsDownloaded = 0; + + const segmentDir = FileSystem.cacheDirectory + "movie-web/segments/"; + await ensureDirExists(segmentDir); + + const updateProgress = () => { + const progress = segmentsDownloaded / totalSegments; + updateDownloadItem(download.id, { + progress, + downloaded: segmentsDownloaded, + fileSize: totalSegments, + }); + }; + + const localSegmentPaths = []; + + for (const [index, segment] of segments.entries()) { + if (getCancellationFlag(download.id)) { + await cleanupDownload(segmentDir, download); + return; } - const totalSegments = segments.length; - let segmentsDownloaded = 0; + const segmentFile = `${segmentDir}${index}.ts`; + localSegmentPaths.push(segmentFile); - const segmentDir = FileSystem.cacheDirectory + "movie-web/segments/"; - await ensureDirExists(segmentDir); + try { + await downloadSegment(segment, segmentFile, headers); - const updateProgress = () => { - const progress = segmentsDownloaded / totalSegments; - updateDownloadItem(download.id, { - progress, - downloaded: segmentsDownloaded, - fileSize: totalSegments, - }); - }; - - const localSegmentPaths = []; - - for (const [index, segment] of segments.entries()) { if (getCancellationFlag(download.id)) { await cleanupDownload(segmentDir, download); return; } - const segmentFile = `${segmentDir}${index}.ts`; - localSegmentPaths.push(segmentFile); - - try { - await downloadSegment(download.id, segment, segmentFile, headers); - - if (getCancellationFlag(download.id)) { - await cleanupDownload(segmentDir, download); - return; - } - - segmentsDownloaded++; - updateProgress(); - } catch (e) { - console.error(e); - if (getCancellationFlag(download.id)) { - await cleanupDownload(segmentDir, download); - return; - } + segmentsDownloaded++; + updateProgress(); + } catch (e) { + console.error(e); + if (getCancellationFlag(download.id)) { + await cleanupDownload(segmentDir, download); + return; } } + } - if (getCancellationFlag(download.id)) { - return removeDownload(download); - } + if (getCancellationFlag(download.id)) { + return removeDownload(download); + } - updateDownloadItem(download.id, { status: "merging" }); - const uri = await VideoManager.mergeVideos( - localSegmentPaths, - `${FileSystem.cacheDirectory}movie-web/output.mp4`, - ); - const asset = await saveFileToMediaLibraryAndDeleteOriginal( - uri, - download, - ); - return asset; - }, - [ - cleanupDownload, - getCancellationFlag, - removeDownload, - saveFileToMediaLibraryAndDeleteOriginal, - updateDownloadItem, - ], - ); + updateDownloadItem(download.id, { status: "merging" }); + const uri = await VideoManager.mergeVideos( + localSegmentPaths, + `${FileSystem.cacheDirectory}movie-web/output.mp4`, + ); + const asset = await saveFileToMediaLibraryAndDeleteOriginal(uri, download); + return asset; + }; - const startDownload = useCallback( - async ( - url: string, - type: "mp4" | "hls", - media: ScrapeMedia, - headers?: Record, - ): Promise => { - const { allowMobileData } = useNetworkSettingsStore.getState(); + const startDownload = async ( + url: string, + type: "mp4" | "hls", + media: ScrapeMedia, + headers?: Record, + ): Promise => { + const { allowMobileData } = useNetworkSettingsStore.getState(); - const { type: networkType } = await Network.getNetworkStateAsync(); + const { type: networkType } = await Network.getNetworkStateAsync(); - if (networkType === NetworkStateType.CELLULAR && !allowMobileData) { - showToast("Mobile data downloads are disabled", { - burntOptions: { preset: "error" }, - }); - return; - } + if (networkType === NetworkStateType.CELLULAR && !allowMobileData) { + showToast("Mobile data downloads are disabled", { + burntOptions: { preset: "error" }, + }); + return; + } - const { status } = await MediaLibrary.requestPermissionsAsync(); - if (status !== MediaLibrary.PermissionStatus.GRANTED) { - showToast("Permission denied", { - burntOptions: { preset: "error" }, - }); - return; - } + const { status } = await MediaLibrary.requestPermissionsAsync(); + if (status !== MediaLibrary.PermissionStatus.GRANTED) { + showToast("Permission denied", { + burntOptions: { preset: "error" }, + }); + return; + } - const existingDownload = downloads.find( - (d) => d.media.tmdbId === media.tmdbId, + const existingDownload = downloads.find( + (d) => d.media.tmdbId === media.tmdbId, + ); + + if (existingDownload && media.type === "movie") { + showToast("Download already exists", { + burntOptions: { preset: "error" }, + }); + return; + } + + if (existingDownload && media.type === "show") { + const existingEpisode = existingDownload.downloads.find( + (d) => + d.media.type === "show" && + d.media.episode.tmdbId === media.episode.tmdbId, ); - if (existingDownload && media.type === "movie") { + if (existingEpisode) { showToast("Download already exists", { burntOptions: { preset: "error" }, }); return; } + } + showToast("Download started", { + burntOptions: { preset: "none" }, + }); - if (existingDownload && media.type === "show") { - const existingEpisode = existingDownload.downloads.find( - (d) => - d.media.type === "show" && - d.media.episode.tmdbId === media.episode.tmdbId, + const newDownload: Download = { + id: `download-${Date.now()}-${Math.random().toString(16).slice(2)}`, + progress: 0, + speed: 0, + fileSize: 0, + downloaded: 0, + type, + url, + status: "downloading", + media, + }; + + if (existingDownload) { + existingDownload.downloads.push(newDownload); + setDownloads((prev) => { + return prev.map((d) => + d.media.tmdbId === media.tmdbId ? existingDownload : d, ); - - if (existingEpisode) { - showToast("Download already exists", { - burntOptions: { preset: "error" }, - }); - return; - } - } - showToast("Download started", { - burntOptions: { preset: "none" }, }); + } else { + setDownloads((prev) => { + return [...prev, { media, downloads: [newDownload] }]; + }); + } - const newDownload: Download = { - id: `download-${Date.now()}-${Math.random().toString(16).slice(2)}`, - progress: 0, - speed: 0, - fileSize: 0, - downloaded: 0, - type, - url, - status: "downloading", - media, - }; - - if (existingDownload) { - existingDownload.downloads.push(newDownload); - setDownloads((prev) => { - return prev.map((d) => - d.media.tmdbId === media.tmdbId ? existingDownload : d, - ); - }); - } else { - setDownloads((prev) => { - return [...prev, { media, downloads: [newDownload] }]; - }); - } - - if (type === "mp4") { - const asset = await downloadMP4(url, newDownload, headers ?? {}); - return asset; - } else if (type === "hls") { - const asset = await downloadHLS(url, newDownload, headers ?? {}); - return asset; - } - }, - [downloadHLS, downloadMP4, downloads, setDownloads, showToast], - ); + if (type === "mp4") { + const asset = await downloadMP4(url, newDownload, headers ?? {}); + return asset; + } else if (type === "hls") { + const asset = await downloadHLS(url, newDownload, headers ?? {}); + return asset; + } + }; const downloadSegment = async ( - downloadId: string, segmentUrl: string, segmentFile: string, headers: Record, ) => { - return new Promise((resolve, reject) => { - const task = download({ - id: `${downloadId}-${segmentUrl.split("/").pop()}`, - url: segmentUrl, - destination: segmentFile, - headers: headers, - }); + const downloadResumable = FileSystem.createDownloadResumable( + segmentUrl, + segmentFile, + { + headers, + }, + ); - task - .done(() => { - resolve(); - }) - .error((error) => { - console.error(error); - reject(error); - }); - }); + try { + const result = await downloadResumable.downloadAsync(); + if (result) { + console.log("Finished downloading to ", result.uri); + } + } catch (e) { + console.error(e); + } }; async function ensureDirExists(dir: string) { @@ -458,27 +409,6 @@ export const useDownloadManager = () => { await FileSystem.makeDirectoryAsync(dir, { intermediates: true }); } - useEffect(() => { - const checkRunningTasks = async () => { - const existingTasks = await checkForExistingDownloads(); - existingTasks.forEach((task) => { - 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}`); - }); - }); - }; - - void checkRunningTasks(); - }, [updateDownloadItem]); - return { startDownload, removeDownload, diff --git a/apps/expo/src/plugins/withRNBackgroundDownloader.js b/apps/expo/src/plugins/withRNBackgroundDownloader.js deleted file mode 100644 index 9185321..0000000 --- a/apps/expo/src/plugins/withRNBackgroundDownloader.js +++ /dev/null @@ -1,48 +0,0 @@ -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 // 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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e5050a..b64df86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,9 +29,6 @@ importers: '@expo/metro-config': specifier: ^0.17.3 version: 0.17.3(@react-native/babel-preset@0.73.21) - '@kesha-antonov/react-native-background-downloader': - specifier: ^3.1.2 - version: 3.1.2(react-native@0.73.6) '@movie-web/api': specifier: '*' version: link:../../packages/api @@ -2842,14 +2839,6 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true - /@kesha-antonov/react-native-background-downloader@3.1.2(react-native@0.73.6): - resolution: {integrity: sha512-xBs1DyGOdGCSI7mfE7cT7V1Ecv6G99A4zh08o0q7/DXOqttX4tymiTQNuWQxW2dMe7clOHGjzeW1BLYwofSYNw==} - peerDependencies: - react-native: '>=0.57.0' - dependencies: - react-native: 0.73.6(@babel/core@7.23.9)(@babel/preset-env@7.23.9)(react@18.2.0) - dev: false - /@motionone/animation@10.17.0: resolution: {integrity: sha512-ANfIN9+iq1kGgsZxs+Nz96uiNcPLGTXwfNo2Xz/fcJXniPYpaz/Uyrfa+7I5BPLxCP82sh7quVDudf1GABqHbg==} dependencies: