mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 14:23:28 +00:00
354 lines
9.7 KiB
TypeScript
354 lines
9.7 KiB
TypeScript
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 VideoManager from "@salihgun/react-native-video-processor";
|
|
import { useToastController } from "@tamagui/toast";
|
|
|
|
import type { ScrapeMedia } from "@movie-web/provider-utils";
|
|
import { extractSegmentsFromHLS } from "@movie-web/provider-utils";
|
|
|
|
import { useDownloadHistoryStore } from "~/stores/settings";
|
|
|
|
export interface DownloadItem {
|
|
id: string;
|
|
filename: string;
|
|
progress: number;
|
|
speed: number;
|
|
fileSize: number;
|
|
downloaded: number;
|
|
url: string;
|
|
type: "mp4" | "hls";
|
|
isFinished: boolean;
|
|
statusText?: string;
|
|
asset?: Asset;
|
|
isHLS?: boolean;
|
|
media: ScrapeMedia;
|
|
downloadResumable?: FileSystem.DownloadResumable;
|
|
}
|
|
|
|
interface DownloadManagerContextType {
|
|
downloads: DownloadItem[];
|
|
startDownload: (
|
|
url: string,
|
|
type: "mp4" | "hls",
|
|
media: ScrapeMedia,
|
|
headers?: Record<string, string>,
|
|
) => Promise<Asset | void>;
|
|
removeDownload: (id: string) => void;
|
|
cancelDownload: (id: string) => Promise<void>;
|
|
}
|
|
|
|
const DownloadManagerContext = createContext<
|
|
DownloadManagerContextType | undefined
|
|
>(undefined);
|
|
|
|
export const useDownloadManager = () => {
|
|
const context = useContext(DownloadManagerContext);
|
|
if (!context) {
|
|
throw new Error(
|
|
"useDownloadManager must be used within a DownloadManagerProvider",
|
|
);
|
|
}
|
|
return context;
|
|
};
|
|
|
|
export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
|
|
children,
|
|
}) => {
|
|
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
|
|
const toastController = useToastController();
|
|
|
|
useEffect(() => {
|
|
const initializeDownloads = () => {
|
|
const { downloads: storedDownloads } = useDownloadHistoryStore.getState();
|
|
if (storedDownloads) {
|
|
setDownloads(storedDownloads);
|
|
}
|
|
};
|
|
|
|
void initializeDownloads();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
useDownloadHistoryStore.setState({ downloads });
|
|
}, [downloads]);
|
|
|
|
const cancellationFlags = useState<Record<string, boolean>>({})[0];
|
|
|
|
const setCancellationFlag = (downloadId: string, flag: boolean): void => {
|
|
cancellationFlags[downloadId] = flag;
|
|
};
|
|
|
|
const getCancellationFlag = (downloadId: string): boolean => {
|
|
return cancellationFlags[downloadId] ?? false;
|
|
};
|
|
|
|
const cancelDownload = async (downloadId: string): Promise<void> => {
|
|
setCancellationFlag(downloadId, true);
|
|
const downloadItem = downloads.find((d) => d.id === downloadId);
|
|
if (downloadItem?.downloadResumable) {
|
|
await downloadItem.downloadResumable.cancelAsync();
|
|
}
|
|
toastController.show("Download cancelled", {
|
|
burntOptions: { preset: "done" },
|
|
native: true,
|
|
duration: 500,
|
|
});
|
|
};
|
|
|
|
const startDownload = async (
|
|
url: string,
|
|
type: "mp4" | "hls",
|
|
media: ScrapeMedia,
|
|
headers?: Record<string, string>,
|
|
): Promise<Asset | void> => {
|
|
toastController.show("Download started", {
|
|
burntOptions: { preset: "none" },
|
|
native: true,
|
|
duration: 500,
|
|
});
|
|
|
|
const newDownload: DownloadItem = {
|
|
id: `download-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
filename: url.split("/").pop() ?? "unknown",
|
|
progress: 0,
|
|
speed: 0,
|
|
fileSize: 0,
|
|
downloaded: 0,
|
|
type,
|
|
url,
|
|
isFinished: false,
|
|
isHLS: type === "hls",
|
|
media,
|
|
};
|
|
|
|
setDownloads((currentDownloads) => [newDownload, ...currentDownloads]);
|
|
|
|
if (type === "mp4") {
|
|
const asset = await downloadMP4(url, newDownload.id, headers ?? {});
|
|
return asset;
|
|
} else if (type === "hls") {
|
|
const asset = await downloadHLS(url, newDownload.id, headers ?? {});
|
|
return asset;
|
|
}
|
|
};
|
|
|
|
const updateDownloadItem = (id: string, updates: Partial<DownloadItem>) => {
|
|
setDownloads((currentDownloads) =>
|
|
currentDownloads.map((download) =>
|
|
download.id === id ? { ...download, ...updates } : download,
|
|
),
|
|
);
|
|
};
|
|
|
|
const downloadMP4 = async (
|
|
url: string,
|
|
downloadId: string,
|
|
headers: Record<string, string>,
|
|
) => {
|
|
let lastBytesWritten = 0;
|
|
let lastTimestamp = Date.now();
|
|
|
|
const callback = (downloadProgress: FileSystem.DownloadProgressData) => {
|
|
const currentTime = Date.now();
|
|
const timeElapsed = (currentTime - lastTimestamp) / 1000;
|
|
|
|
if (timeElapsed === 0) return;
|
|
|
|
const bytesWritten = downloadProgress.totalBytesWritten;
|
|
const newBytes = bytesWritten - lastBytesWritten;
|
|
const speed = newBytes / timeElapsed / 1024;
|
|
const progress =
|
|
bytesWritten / downloadProgress.totalBytesExpectedToWrite;
|
|
|
|
updateDownloadItem(downloadId, {
|
|
progress,
|
|
speed,
|
|
fileSize: downloadProgress.totalBytesExpectedToWrite,
|
|
downloaded: bytesWritten,
|
|
});
|
|
|
|
lastBytesWritten = bytesWritten;
|
|
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;
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const downloadHLS = async (
|
|
url: string,
|
|
downloadId: string,
|
|
headers: Record<string, string>,
|
|
) => {
|
|
const segments = await extractSegmentsFromHLS(url, headers);
|
|
|
|
if (!segments || segments.length === 0) {
|
|
return removeDownload(downloadId);
|
|
}
|
|
|
|
const totalSegments = segments.length;
|
|
let segmentsDownloaded = 0;
|
|
|
|
const segmentDir = FileSystem.cacheDirectory + "segments/";
|
|
await ensureDirExists(segmentDir);
|
|
|
|
const updateProgress = () => {
|
|
const progress = segmentsDownloaded / totalSegments;
|
|
updateDownloadItem(downloadId, {
|
|
progress,
|
|
downloaded: segmentsDownloaded,
|
|
fileSize: totalSegments,
|
|
});
|
|
};
|
|
|
|
const localSegmentPaths = [];
|
|
|
|
for (const [index, segment] of segments.entries()) {
|
|
if (getCancellationFlag(downloadId)) {
|
|
await cleanupDownload(segmentDir, downloadId);
|
|
return;
|
|
}
|
|
|
|
const segmentFile = `${segmentDir}${index}.ts`;
|
|
localSegmentPaths.push(segmentFile);
|
|
|
|
try {
|
|
await downloadSegment(segment, segmentFile, headers);
|
|
|
|
if (getCancellationFlag(downloadId)) {
|
|
await cleanupDownload(segmentDir, downloadId);
|
|
return;
|
|
}
|
|
|
|
segmentsDownloaded++;
|
|
updateProgress();
|
|
} catch (e) {
|
|
console.error(e);
|
|
if (getCancellationFlag(downloadId)) {
|
|
await cleanupDownload(segmentDir, downloadId);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (getCancellationFlag(downloadId)) {
|
|
return removeDownload(downloadId);
|
|
}
|
|
|
|
updateDownloadItem(downloadId, { statusText: "Merging" });
|
|
const uri = await VideoManager.mergeVideos(
|
|
localSegmentPaths,
|
|
`${FileSystem.cacheDirectory}output.mp4`,
|
|
);
|
|
const asset = await saveFileToMediaLibraryAndDeleteOriginal(
|
|
uri,
|
|
downloadId,
|
|
);
|
|
return asset;
|
|
};
|
|
|
|
const downloadSegment = async (
|
|
segmentUrl: string,
|
|
segmentFile: string,
|
|
headers: Record<string, string>,
|
|
) => {
|
|
const downloadResumable = FileSystem.createDownloadResumable(
|
|
segmentUrl,
|
|
segmentFile,
|
|
{ headers },
|
|
);
|
|
await downloadResumable.downloadAsync();
|
|
};
|
|
|
|
const cleanupDownload = async (segmentDir: string, downloadId: string) => {
|
|
await FileSystem.deleteAsync(segmentDir, { idempotent: true });
|
|
removeDownload(downloadId);
|
|
};
|
|
|
|
async function ensureDirExists(dir: string) {
|
|
await FileSystem.deleteAsync(dir, { idempotent: true });
|
|
await FileSystem.makeDirectoryAsync(dir, { intermediates: true });
|
|
}
|
|
|
|
const saveFileToMediaLibraryAndDeleteOriginal = async (
|
|
fileUri: string,
|
|
downloadId: string,
|
|
): Promise<Asset | void> => {
|
|
try {
|
|
updateDownloadItem(downloadId, { statusText: "Importing" });
|
|
|
|
const { status } = await MediaLibrary.requestPermissionsAsync();
|
|
if (status !== MediaLibrary.PermissionStatus.GRANTED) {
|
|
throw new Error("MediaLibrary permission not granted");
|
|
}
|
|
|
|
const asset = await MediaLibrary.createAssetAsync(fileUri);
|
|
await FileSystem.deleteAsync(fileUri);
|
|
|
|
updateDownloadItem(downloadId, {
|
|
statusText: undefined,
|
|
asset,
|
|
isFinished: true,
|
|
});
|
|
console.log("File saved to media library and original deleted");
|
|
toastController.show("Download finished", {
|
|
burntOptions: { preset: "done" },
|
|
native: true,
|
|
duration: 500,
|
|
});
|
|
return asset;
|
|
} catch (error) {
|
|
console.error("Error saving file to media library:", error);
|
|
toastController.show("Download failed", {
|
|
burntOptions: { preset: "error" },
|
|
native: true,
|
|
duration: 500,
|
|
});
|
|
}
|
|
};
|
|
|
|
const removeDownload = (id: string) => {
|
|
const updatedDownloads = downloads.filter((download) => download.id !== id);
|
|
setDownloads(updatedDownloads);
|
|
useDownloadHistoryStore.setState({ downloads: updatedDownloads });
|
|
};
|
|
|
|
return (
|
|
<DownloadManagerContext.Provider
|
|
value={{ downloads, startDownload, removeDownload, cancelDownload }}
|
|
>
|
|
{children}
|
|
</DownloadManagerContext.Provider>
|
|
);
|
|
};
|