mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 16:43:25 +00:00
feat: hls downloads
This commit is contained in:
@@ -27,6 +27,9 @@ const defineConfig = (): ExpoConfig => ({
|
||||
CFBundleName: "movie-web",
|
||||
NSPhotoLibraryUsageDescription:
|
||||
"This app saves videos to the photo library.",
|
||||
NSAppTransportSecurity: {
|
||||
NSAllowsArbitraryLoads: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
android: {
|
||||
|
@@ -25,6 +25,7 @@
|
||||
"@octokit/rest": "^20.0.2",
|
||||
"@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0",
|
||||
"@react-navigation/native": "^6.1.9",
|
||||
"@salihgun/react-native-video-processor": "^0.3.1",
|
||||
"@tamagui/animations-moti": "^1.91.4",
|
||||
"@tamagui/babel-plugin": "^1.91.4",
|
||||
"@tamagui/config": "^1.91.4",
|
||||
|
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { ScrollView } from "react-native-gesture-handler";
|
||||
import { useRouter } from "expo-router";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { useTheme } from "tamagui";
|
||||
import { useTheme, YStack } from "tamagui";
|
||||
|
||||
import { DownloadItem } from "~/components/DownloadItem";
|
||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||
@@ -29,28 +29,46 @@ const DownloadsScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<ScreenLayout>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="download"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={async () => {
|
||||
const asset = await startDownload(
|
||||
"https://samplelib.com/lib/preview/mp4/sample-5s.mp4",
|
||||
"mp4",
|
||||
).catch(console.error);
|
||||
if (asset) {
|
||||
handlePress(asset);
|
||||
<YStack space={2} style={{ padding: 10 }}>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="download"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
}}
|
||||
>
|
||||
test download (mp4)
|
||||
</MWButton>
|
||||
onPress={async () => {
|
||||
await startDownload(
|
||||
"https://samplelib.com/lib/preview/mp4/sample-5s.mp4",
|
||||
"mp4",
|
||||
).catch(console.error);
|
||||
}}
|
||||
>
|
||||
test download (mp4)
|
||||
</MWButton>
|
||||
<MWButton
|
||||
type="secondary"
|
||||
backgroundColor="$sheetItemBackground"
|
||||
icon={
|
||||
<MaterialCommunityIcons
|
||||
name="download"
|
||||
size={24}
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={async () => {
|
||||
await startDownload(
|
||||
"http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8",
|
||||
"hls",
|
||||
).catch(console.error);
|
||||
}}
|
||||
>
|
||||
test download (hls)
|
||||
</MWButton>
|
||||
</YStack>
|
||||
<ScrollView>
|
||||
{downloads.map((item) => (
|
||||
<DownloadItem
|
||||
|
@@ -15,6 +15,7 @@ export interface DownloadItemProps {
|
||||
statusText?: string;
|
||||
asset?: Asset;
|
||||
onPress: (asset?: Asset) => void;
|
||||
isHLS?: boolean;
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number, decimals = 2) => {
|
||||
@@ -38,6 +39,7 @@ export const DownloadItem: React.FC<DownloadItemProps> = ({
|
||||
statusText,
|
||||
asset,
|
||||
onPress,
|
||||
isHLS,
|
||||
}) => {
|
||||
const percentage = progress * 100;
|
||||
const formattedFileSize = formatBytes(fileSize);
|
||||
@@ -60,6 +62,7 @@ export const DownloadItem: React.FC<DownloadItemProps> = ({
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
if (isHLS) return null;
|
||||
return (
|
||||
<Text fontSize={12} color="gray">
|
||||
{speed.toFixed(2)} MB/s
|
||||
@@ -95,8 +98,9 @@ export const DownloadItem: React.FC<DownloadItemProps> = ({
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Text fontSize={12} color="gray">
|
||||
{percentage.toFixed()}% - {formattedDownloaded} of{" "}
|
||||
{formattedFileSize}
|
||||
{isHLS
|
||||
? `${percentage.toFixed()}% - ${downloaded} of ${fileSize} segments`
|
||||
: `${percentage.toFixed()}% - ${formattedDownloaded} of ${formattedFileSize}`}
|
||||
</Text>
|
||||
{renderStatus()}
|
||||
</View>
|
||||
|
@@ -12,10 +12,15 @@ export const DownloadButton = () => {
|
||||
const theme = useTheme();
|
||||
const stream = usePlayerStore((state) => state.interface.currentStream);
|
||||
const { startDownload } = useDownloadManager();
|
||||
if (stream?.type !== "file") return null;
|
||||
let url: string | undefined | null = null;
|
||||
|
||||
if (stream?.type === "file") {
|
||||
const highestQuality = findHighestQuality(stream);
|
||||
url = highestQuality ? stream.qualities[highestQuality]?.url : null;
|
||||
} else if (stream?.type === "hls") {
|
||||
url = stream.playlist;
|
||||
}
|
||||
|
||||
const highestQuality = findHighestQuality(stream);
|
||||
const url = highestQuality ? stream.qualities[highestQuality]?.url : null;
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
@@ -30,7 +35,9 @@ export const DownloadButton = () => {
|
||||
color={theme.buttonSecondaryText.val}
|
||||
/>
|
||||
}
|
||||
onPress={() => startDownload(url, "mp4")}
|
||||
onPress={() =>
|
||||
url && startDownload(url, stream?.type === "hls" ? "hls" : "mp4")
|
||||
}
|
||||
>
|
||||
Download
|
||||
</MWButton>
|
||||
|
@@ -2,7 +2,8 @@ import { useState } from "react";
|
||||
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
|
||||
import { useTheme } from "tamagui";
|
||||
|
||||
import { constructFullUrl } from "~/lib/url";
|
||||
import { constructFullUrl } from "@movie-web/provider-utils";
|
||||
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { MWButton } from "../ui/Button";
|
||||
import { Controls } from "./Controls";
|
||||
|
@@ -10,6 +10,7 @@ import type {
|
||||
ScrapeMedia,
|
||||
} from "@movie-web/provider-utils";
|
||||
import {
|
||||
constructFullUrl,
|
||||
extractTracksFromHLS,
|
||||
findHighestQuality,
|
||||
} from "@movie-web/provider-utils";
|
||||
@@ -21,7 +22,6 @@ import { useDownloadManager } from "~/hooks/DownloadManagerContext";
|
||||
import { useMeta } from "~/hooks/player/useMeta";
|
||||
import { useScrape } from "~/hooks/player/useSourceScrape";
|
||||
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
||||
import { constructFullUrl } from "~/lib/url";
|
||||
import { PlayerStatus } from "~/stores/player/slices/interface";
|
||||
import { usePlayerStore } from "~/stores/player/store";
|
||||
import { ScrapeCard, ScrapeItem } from "./ScrapeCard";
|
||||
@@ -76,6 +76,10 @@ export const ScraperProcess = ({
|
||||
: null;
|
||||
if (!url) return;
|
||||
startDownload(url, "mp4").catch(console.error);
|
||||
} else if (streamResult.stream.type === "hls") {
|
||||
startDownload(streamResult.stream.playlist, "hls").catch(
|
||||
console.error,
|
||||
);
|
||||
}
|
||||
return router.back();
|
||||
}
|
||||
|
@@ -3,8 +3,11 @@ 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 { extractSegmentsFromHLS } from "@movie-web/provider-utils";
|
||||
|
||||
import { useDownloadHistoryStore } from "~/stores/settings";
|
||||
|
||||
export interface DownloadItem {
|
||||
@@ -19,6 +22,7 @@ export interface DownloadItem {
|
||||
isFinished: boolean;
|
||||
statusText?: string;
|
||||
asset?: Asset;
|
||||
isHLS?: boolean;
|
||||
}
|
||||
|
||||
interface DownloadManagerContextType {
|
||||
@@ -83,6 +87,7 @@ export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
|
||||
type,
|
||||
url,
|
||||
isFinished: false,
|
||||
isHLS: type === "hls",
|
||||
};
|
||||
|
||||
setDownloads((currentDownloads) => [newDownload, ...currentDownloads]);
|
||||
@@ -91,7 +96,8 @@ export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const asset = await downloadMP4(url, newDownload.id, headers ?? {});
|
||||
return asset;
|
||||
} else if (type === "hls") {
|
||||
// HLS stuff later
|
||||
const asset = await downloadHLS(url, newDownload.id, headers ?? {});
|
||||
return asset;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -134,11 +140,11 @@ export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
|
||||
lastTimestamp = currentTime;
|
||||
};
|
||||
|
||||
const fileUri = FileSystem.documentDirectory
|
||||
? FileSystem.documentDirectory + url.split("/").pop()
|
||||
const fileUri = FileSystem.cacheDirectory
|
||||
? FileSystem.cacheDirectory + url.split("/").pop()
|
||||
: null;
|
||||
if (!fileUri) {
|
||||
console.error("Document directory is unavailable");
|
||||
console.error("Cache directory is unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -164,6 +170,69 @@ export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
};
|
||||
|
||||
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()) {
|
||||
const segmentFile = `${segmentDir}${index}.ts`;
|
||||
localSegmentPaths.push(segmentFile);
|
||||
const downloadResumable = FileSystem.createDownloadResumable(
|
||||
segment,
|
||||
segmentFile,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
try {
|
||||
await downloadResumable.downloadAsync();
|
||||
segmentsDownloaded++;
|
||||
updateProgress();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
updateDownloadItem(downloadId, { statusText: "Merging" });
|
||||
const uri = await VideoManager.mergeVideos(
|
||||
localSegmentPaths,
|
||||
`${FileSystem.cacheDirectory}output.mp4`,
|
||||
);
|
||||
const asset = await saveFileToMediaLibraryAndDeleteOriginal(
|
||||
uri,
|
||||
downloadId,
|
||||
);
|
||||
return asset;
|
||||
};
|
||||
|
||||
async function ensureDirExists(dir: string) {
|
||||
await FileSystem.deleteAsync(dir, { idempotent: true });
|
||||
await FileSystem.makeDirectoryAsync(dir, { intermediates: true });
|
||||
}
|
||||
|
||||
const saveFileToMediaLibraryAndDeleteOriginal = async (
|
||||
fileUri: string,
|
||||
downloadId: string,
|
||||
|
@@ -1,6 +0,0 @@
|
||||
export const constructFullUrl = (playlistUrl: string, uri: string) => {
|
||||
const baseUrl = playlistUrl.substring(0, playlistUrl.lastIndexOf("/") + 1);
|
||||
return uri.startsWith("http://") || uri.startsWith("https://")
|
||||
? uri
|
||||
: baseUrl + uri;
|
||||
};
|
Reference in New Issue
Block a user