mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 10:33:26 +00:00
Compare commits
1 Commits
daba32627b
...
f6a265f17b
Author | SHA1 | Date | |
---|---|---|---|
|
f6a265f17b |
@@ -41,7 +41,7 @@ export default function Page() {
|
||||
title: download?.media.title ?? "Downloads",
|
||||
}}
|
||||
/>
|
||||
<YStack gap="$4">
|
||||
<YStack gap="$3">
|
||||
{download?.downloads.map((download) => {
|
||||
return (
|
||||
<DownloadItem
|
||||
|
@@ -37,7 +37,7 @@ const formatBytes = (bytes: number, decimals = 2) => {
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
export function DownloadItem(props: DownloadItemProps) {
|
||||
@@ -100,19 +100,14 @@ export function DownloadItem(props: DownloadItemProps) {
|
||||
height="100%"
|
||||
/>
|
||||
</View>
|
||||
<YStack gap="$2" flex={1}>
|
||||
<XStack justifyContent="space-between" alignItems="center">
|
||||
<Text
|
||||
fontWeight="$bold"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
flex={1}
|
||||
>
|
||||
<YStack gap="$2">
|
||||
<XStack gap="$6" maxWidth="65%">
|
||||
<Text fontWeight="$bold" ellipse flexGrow={1}>
|
||||
{props.item.media.type === "show" &&
|
||||
`${mapSeasonAndEpisodeNumberToText(
|
||||
mapSeasonAndEpisodeNumberToText(
|
||||
props.item.media.season.number,
|
||||
props.item.media.episode.number,
|
||||
)} `}
|
||||
) + " "}
|
||||
{props.item.media.title}
|
||||
</Text>
|
||||
{props.item.type !== "hls" && (
|
||||
|
@@ -43,7 +43,7 @@ export default function ScreenLayout({
|
||||
start={[0, 0]}
|
||||
end={[1, 1]}
|
||||
flexGrow={1}
|
||||
paddingTop={showHeader ? insets.top + 16 : insets.top + 50}
|
||||
paddingTop={showHeader ? insets.top : insets.top + 50}
|
||||
>
|
||||
{showHeader && <Header />}
|
||||
<ScrollView
|
||||
|
@@ -28,9 +28,11 @@ export const BottomControls = () => {
|
||||
const { currentTime, remainingTime } = useMemo(() => {
|
||||
if (status?.isLoaded) {
|
||||
const current = mapMillisecondsToTime(status.positionMillis ?? 0);
|
||||
const remaining = `-${mapMillisecondsToTime(
|
||||
(status.durationMillis ?? 0) - (status.positionMillis ?? 0),
|
||||
)}`;
|
||||
const remaining =
|
||||
"-" +
|
||||
mapMillisecondsToTime(
|
||||
(status.durationMillis ?? 0) - (status.positionMillis ?? 0),
|
||||
);
|
||||
return { currentTime: current, remainingTime: remaining };
|
||||
} else {
|
||||
return { currentTime: "", remainingTime: "" };
|
||||
|
@@ -124,103 +124,97 @@ export const useDownloadManager = () => {
|
||||
[setDownloads],
|
||||
);
|
||||
|
||||
const saveFileToMediaLibraryAndDeleteOriginal = useCallback(
|
||||
async (fileUri: string, download: Download): Promise<Asset | void> => {
|
||||
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<Asset | void> => {
|
||||
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(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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const downloadMP4 = async (
|
||||
url: string,
|
||||
downloadItem: Download,
|
||||
headers: Record<string, string>,
|
||||
): Promise<Asset | void> => {
|
||||
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${url.split("/").pop()}`;
|
||||
if (
|
||||
!(await FileSystem.getInfoAsync(`${FileSystem.cacheDirectory}movie-web`))
|
||||
.exists
|
||||
) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
},
|
||||
[updateDownloadItem, showToast],
|
||||
);
|
||||
|
||||
const downloadMP4 = useCallback(
|
||||
async (
|
||||
url: string,
|
||||
downloadItem: Download,
|
||||
headers: Record<string, string>,
|
||||
): Promise<Asset | void> => {
|
||||
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/${url.split("/").pop()}`;
|
||||
if (
|
||||
!(
|
||||
await FileSystem.getInfoAsync(`${FileSystem.cacheDirectory}movie-web`)
|
||||
).exists
|
||||
) {
|
||||
await ensureDirExists(`${FileSystem.cacheDirectory}movie-web`);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
[updateDownloadItem, saveFileToMediaLibraryAndDeleteOriginal],
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupDownload = useCallback(
|
||||
async (segmentDir: string, download: Download) => {
|
||||
@@ -230,177 +224,162 @@ export const useDownloadManager = () => {
|
||||
[removeDownload],
|
||||
);
|
||||
|
||||
const downloadHLS = useCallback(
|
||||
async (
|
||||
url: string,
|
||||
download: Download,
|
||||
headers: Record<string, string>,
|
||||
) => {
|
||||
const segments = await extractSegmentsFromHLS(url, headers);
|
||||
const downloadHLS = async (
|
||||
url: string,
|
||||
download: Download,
|
||||
headers: Record<string, string>,
|
||||
) => {
|
||||
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(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;
|
||||
},
|
||||
[
|
||||
getCancellationFlag,
|
||||
updateDownloadItem,
|
||||
saveFileToMediaLibraryAndDeleteOriginal,
|
||||
removeDownload,
|
||||
cleanupDownload,
|
||||
],
|
||||
);
|
||||
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<string, string>,
|
||||
): Promise<Asset | void> => {
|
||||
const { allowMobileData } = useNetworkSettingsStore.getState();
|
||||
const startDownload = async (
|
||||
url: string,
|
||||
type: "mp4" | "hls",
|
||||
media: ScrapeMedia,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<Asset | void> => {
|
||||
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;
|
||||
}
|
||||
},
|
||||
[downloads, showToast, setDownloads, downloadMP4, downloadHLS],
|
||||
);
|
||||
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 (
|
||||
segmentUrl: string,
|
||||
|
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"prettier": "@movie-web/prettier-config",
|
||||
"dependencies": {
|
||||
"@movie-web/providers": "^2.3.0",
|
||||
"@movie-web/providers": "^2.2.9",
|
||||
"parse-hls": "^1.0.7",
|
||||
"srt-webvtt": "^2.0.0",
|
||||
"tmdb-ts": "^1.6.1"
|
||||
|
@@ -228,7 +228,7 @@ export async function findHLSQuality(
|
||||
const chosenQuality = sortedStreams[highest ? 0 : sortedStreams.length - 1];
|
||||
if (!chosenQuality) return null;
|
||||
|
||||
return constructFullUrl(playlistUrl, chosenQuality.uri);
|
||||
return chosenQuality.uri;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
590
pnpm-lock.yaml
generated
590
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user