Compare commits

..

1 Commits

Author SHA1 Message Date
Adrian Castro
a38ee9ccdb Merge ae5505da7f into a3f184979e 2024-04-08 18:36:52 +02:00
14 changed files with 467 additions and 493 deletions

View File

@@ -2,6 +2,7 @@ import type { ExpoConfig } from "expo/config";
import { version } from "./package.json"; import { version } from "./package.json";
import withRemoveiOSNotificationEntitlement from "./src/plugins/withRemoveiOSNotificationEntitlement"; import withRemoveiOSNotificationEntitlement from "./src/plugins/withRemoveiOSNotificationEntitlement";
import withRNBackgroundDownloader from "./src/plugins/withRNBackgroundDownloader";
const defineConfig = (): ExpoConfig => ({ const defineConfig = (): ExpoConfig => ({
name: "movie-web", name: "movie-web",
@@ -47,6 +48,7 @@ const defineConfig = (): ExpoConfig => ({
plugins: [ plugins: [
"expo-router", "expo-router",
[withRemoveiOSNotificationEntitlement as unknown as string], [withRemoveiOSNotificationEntitlement as unknown as string],
[withRNBackgroundDownloader as unknown as string],
[ [
"expo-screen-orientation", "expo-screen-orientation",
{ {

View File

@@ -19,6 +19,7 @@
}, },
"dependencies": { "dependencies": {
"@expo/metro-config": "^0.17.3", "@expo/metro-config": "^0.17.3",
"@kesha-antonov/react-native-background-downloader": "^3.1.2",
"@movie-web/api": "*", "@movie-web/api": "*",
"@movie-web/colors": "*", "@movie-web/colors": "*",
"@movie-web/provider-utils": "*", "@movie-web/provider-utils": "*",

View File

@@ -1,61 +0,0 @@
import { useMemo } from "react";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { YStack } from "tamagui";
import { DownloadItem } from "~/components/DownloadItem";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { PlayerStatus } from "~/stores/player/slices/interface";
import { usePlayerStore } from "~/stores/player/store";
import { useDownloadHistoryStore } from "~/stores/settings";
export default function Page() {
const { tmdbId } = useLocalSearchParams();
const allDownloads = useDownloadHistoryStore((state) => state.downloads);
const resetVideo = usePlayerStore((state) => state.resetVideo);
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile);
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
const router = useRouter();
const download = useMemo(() => {
return allDownloads.find((download) => download.media.tmdbId === tmdbId);
}, [allDownloads, tmdbId]);
const handlePress = (localPath?: string) => {
if (!localPath) return;
resetVideo();
setIsLocalFile(true);
setPlayerStatus(PlayerStatus.READY);
setVideoSrc({
uri: localPath,
});
router.push({
pathname: "/videoPlayer",
});
};
return (
<ScreenLayout showHeader={false}>
<Stack.Screen
options={{
title: download?.media.title ?? "Downloads",
}}
/>
<YStack gap="$3">
{download?.downloads.map((download) => {
return (
<DownloadItem
key={
download.media.type === "show"
? download.media.episode.tmdbId
: download.media.tmdbId
}
item={download}
onPress={() => handlePress(download.localPath)}
/>
);
})}
</YStack>
</ScreenLayout>
);
}

View File

@@ -1,14 +0,0 @@
import { Stack } from "expo-router";
import { BrandPill } from "~/components/BrandPill";
export default function Layout() {
return (
<Stack
screenOptions={{
headerTransparent: true,
headerRight: BrandPill,
}}
/>
);
}

View File

@@ -7,7 +7,7 @@ import { ScrollView, useTheme, YStack } from "tamagui";
import type { ScrapeMedia } from "@movie-web/provider-utils"; import type { ScrapeMedia } from "@movie-web/provider-utils";
import { DownloadItem, ShowDownloadItem } from "~/components/DownloadItem"; import { DownloadItem } from "~/components/DownloadItem";
import ScreenLayout from "~/components/layout/ScreenLayout"; import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button"; import { MWButton } from "~/components/ui/Button";
import { useDownloadManager } from "~/hooks/useDownloadManager"; import { useDownloadManager } from "~/hooks/useDownloadManager";
@@ -15,69 +15,15 @@ import { PlayerStatus } from "~/stores/player/slices/interface";
import { usePlayerStore } from "~/stores/player/store"; import { usePlayerStore } from "~/stores/player/store";
import { useDownloadHistoryStore } from "~/stores/settings"; import { useDownloadHistoryStore } from "~/stores/settings";
const exampleMovieMedia: ScrapeMedia = {
type: "movie",
title: "Avengers: Endgame",
releaseYear: 2019,
imdbId: "tt4154796",
tmdbId: "299534",
};
const getExampleShowMedia = (seasonNumber: number, episodeNumber: number) =>
({
type: "show",
title: "Loki",
releaseYear: 2021,
imdbId: "tt9140554",
tmdbId: "84958",
season: {
number: seasonNumber,
tmdbId: seasonNumber.toString(),
},
episode: {
number: episodeNumber,
tmdbId: episodeNumber.toString(),
},
}) as const;
const TestDownloadButton = (props: {
media: ScrapeMedia;
type: "hls" | "mp4";
url: string;
}) => {
const { startDownload } = useDownloadManager();
const theme = useTheme();
return (
<MWButton
type="secondary"
backgroundColor="$sheetItemBackground"
icon={
<MaterialCommunityIcons
name="download"
size={24}
color={theme.buttonSecondaryText.val}
/>
}
onPress={async () => {
await startDownload(props.url, props.type, props.media).catch(
console.error,
);
}}
>
test download
{props.type === "hls" ? " (hls)" : "(mp4)"}{" "}
{props.media.type === "show" ? "show" : "movie"}
</MWButton>
);
};
const DownloadsScreen: React.FC = () => { const DownloadsScreen: React.FC = () => {
const { startDownload } = useDownloadManager();
const downloads = useDownloadHistoryStore((state) => state.downloads); const downloads = useDownloadHistoryStore((state) => state.downloads);
const resetVideo = usePlayerStore((state) => state.resetVideo); const resetVideo = usePlayerStore((state) => state.resetVideo);
const setVideoSrc = usePlayerStore((state) => state.setVideoSrc); const setVideoSrc = usePlayerStore((state) => state.setVideoSrc);
const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile); const setIsLocalFile = usePlayerStore((state) => state.setIsLocalFile);
const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus); const setPlayerStatus = usePlayerStore((state) => state.setPlayerStatus);
const router = useRouter(); const router = useRouter();
const theme = useTheme();
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
@@ -109,54 +55,85 @@ const DownloadsScreen: React.FC = () => {
}); });
}; };
const exampleShowMedia: ScrapeMedia = {
type: "show",
title: "Example Show Title",
releaseYear: 2022,
imdbId: "tt1234567",
tmdbId: "12345",
season: {
number: 1,
tmdbId: "54321",
},
episode: {
number: 3,
tmdbId: "98765",
},
};
return ( return (
<ScreenLayout> <ScreenLayout>
<YStack gap={2} style={{ padding: 10 }}> <YStack gap={2} style={{ padding: 10 }}>
<TestDownloadButton <MWButton
media={exampleMovieMedia} type="secondary"
type="mp4" backgroundColor="$sheetItemBackground"
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4" icon={
/> <MaterialCommunityIcons
<TestDownloadButton name="download"
media={getExampleShowMedia(1, 1)} size={24}
type="mp4" color={theme.buttonSecondaryText.val}
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4" />
/> }
<TestDownloadButton onPress={async () => {
media={getExampleShowMedia(1, 2)} await startDownload(
type="mp4" "https://samplelib.com/lib/preview/mp4/sample-5s.mp4",
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4" "mp4",
/> exampleShowMedia,
<TestDownloadButton ).catch(console.error);
media={getExampleShowMedia(1, 1)} }}
type="hls" >
url="http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8" 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",
{
...exampleShowMedia,
tmdbId: "123456",
},
).catch(console.error);
}}
>
test download (hls)
</MWButton>
</YStack> </YStack>
<ScrollView <ScrollView
contentContainerStyle={{ contentContainerStyle={{
gap: "$4", gap: "$4",
}} }}
> >
{downloads.map((download) => { {/* TODO: Differentiate movies/shows, shows in new page */}
if (download.downloads.length === 0) return null; {downloads
if (download.media.type === "movie") { .map((item) => item.downloads)
return ( .flat()
<DownloadItem .map((item) => (
key={download.media.tmdbId} <DownloadItem
item={download.downloads[0]!} key={item.id}
onPress={() => handlePress(download.downloads[0]!.localPath)} item={item}
/> onPress={() => handlePress(item.localPath)}
); />
} else { ))}
return (
<ShowDownloadItem
key={download.media.tmdbId}
download={download}
/>
);
}
})}
</ScrollView> </ScrollView>
</ScreenLayout> </ScreenLayout>
); );

View File

@@ -3,12 +3,10 @@ import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-vi
import React from "react"; import React from "react";
import ContextMenu from "react-native-context-menu-view"; import ContextMenu from "react-native-context-menu-view";
import { TouchableOpacity } from "react-native-gesture-handler"; import { TouchableOpacity } from "react-native-gesture-handler";
import { useRouter } from "expo-router";
import { Image, Text, View, XStack, YStack } from "tamagui"; import { Image, Text, View, XStack, YStack } from "tamagui";
import type { Download, DownloadContent } from "~/hooks/useDownloadManager"; import type { Download } from "~/hooks/useDownloadManager";
import { useDownloadManager } from "~/hooks/useDownloadManager"; import { useDownloadManager } from "~/hooks/useDownloadManager";
import { mapSeasonAndEpisodeNumberToText } from "./player/utils";
import { MWProgress } from "./ui/Progress"; import { MWProgress } from "./ui/Progress";
import { FlashingText } from "./ui/Text"; import { FlashingText } from "./ui/Text";
@@ -103,11 +101,6 @@ export function DownloadItem(props: DownloadItemProps) {
<YStack gap="$2"> <YStack gap="$2">
<XStack gap="$6" maxWidth="65%"> <XStack gap="$6" maxWidth="65%">
<Text fontWeight="$bold" ellipse flexGrow={1}> <Text fontWeight="$bold" ellipse flexGrow={1}>
{props.item.media.type === "show" &&
mapSeasonAndEpisodeNumberToText(
props.item.media.season.number,
props.item.media.episode.number,
) + " "}
{props.item.media.title} {props.item.media.title}
</Text> </Text>
{props.item.type !== "hls" && ( {props.item.type !== "hls" && (
@@ -143,54 +136,3 @@ export function DownloadItem(props: DownloadItemProps) {
</ContextMenu> </ContextMenu>
); );
} }
export function ShowDownloadItem({ download }: { download: DownloadContent }) {
const router = useRouter();
return (
<TouchableOpacity
onPress={() =>
router.push({
pathname: "/(downloads)/[tmdbId]",
params: { tmdbId: download.media.tmdbId },
})
}
activeOpacity={0.7}
>
<XStack gap="$4" alignItems="center">
<View
aspectRatio={9 / 14}
width={70}
maxHeight={180}
overflow="hidden"
borderRadius="$2"
>
<Image
source={{
uri: "https://image.tmdb.org/t/p/original//or06FN3Dka5tukK1e9sl16pB3iy.jpg",
}}
width="100%"
height="100%"
/>
</View>
<YStack gap="$2">
<YStack gap="$1">
<Text fontWeight="$bold" ellipse flexGrow={1} fontSize="$5">
{download.media.title}
</Text>
<Text fontSize="$2">
{download.downloads.length} Episode
{download.downloads.length > 1 ? "s" : ""} |{" "}
{formatBytes(
download.downloads.reduce(
(acc, curr) => acc + curr.fileSize,
0,
),
)}
</Text>
</YStack>
</YStack>
</XStack>
</TouchableOpacity>
);
}

View File

@@ -124,7 +124,6 @@ export default function Item({ data }: { data: ItemData }) {
width="100%" width="100%"
overflow="hidden" overflow="hidden"
borderRadius={24} borderRadius={24}
height="$14"
> >
<Image source={{ uri: posterUrl }} width="100%" height="100%" /> <Image source={{ uri: posterUrl }} width="100%" height="100%" />
</View> </View>

View File

@@ -1,4 +1,5 @@
import { Linking } from "react-native"; import { Linking } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { FontAwesome6, MaterialIcons } from "@expo/vector-icons"; import { FontAwesome6, MaterialIcons } from "@expo/vector-icons";
import { Circle, View } from "tamagui"; import { Circle, View } from "tamagui";
@@ -7,13 +8,20 @@ import { DISCORD_LINK, GITHUB_LINK } from "~/constants/core";
import { BrandPill } from "../BrandPill"; import { BrandPill } from "../BrandPill";
export function Header() { export function Header() {
const insets = useSafeAreaInsets();
return ( return (
<View alignItems="center" gap="$3" flexDirection="row"> <View
paddingTop={insets.top}
alignItems="center"
gap="$3"
flexDirection="row"
>
<BrandPill /> <BrandPill />
<Circle <Circle
backgroundColor="$pillBackground" backgroundColor="$pillBackground"
size="$3.5" size="$4.5"
pressStyle={{ pressStyle={{
opacity: 1, opacity: 1,
scale: 1.05, scale: 1.05,
@@ -25,11 +33,11 @@ export function Header() {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
} }
> >
<MaterialIcons name="discord" size={28} color="white" /> <MaterialIcons name="discord" size={32} color="white" />
</Circle> </Circle>
<Circle <Circle
backgroundColor="$pillBackground" backgroundColor="$pillBackground"
size="$3.5" size="$4.5"
pressStyle={{ pressStyle={{
opacity: 1, opacity: 1,
scale: 1.05, scale: 1.05,
@@ -41,7 +49,7 @@ export function Header() {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
} }
> >
<FontAwesome6 name="github" size={28} color="white" /> <FontAwesome6 name="github" size={32} color="white" />
</Circle> </Circle>
</View> </View>
); );

View File

@@ -1,4 +1,3 @@
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScrollView } from "tamagui"; import { ScrollView } from "tamagui";
import { LinearGradient } from "tamagui/linear-gradient"; import { LinearGradient } from "tamagui/linear-gradient";
@@ -8,7 +7,6 @@ interface Props {
children?: React.ReactNode; children?: React.ReactNode;
onScrollBeginDrag?: () => void; onScrollBeginDrag?: () => void;
onMomentumScrollEnd?: () => void; onMomentumScrollEnd?: () => void;
showHeader?: boolean;
scrollEnabled?: boolean; scrollEnabled?: boolean;
keyboardDismissMode?: "none" | "on-drag" | "interactive"; keyboardDismissMode?: "none" | "on-drag" | "interactive";
keyboardShouldPersistTaps?: "always" | "never" | "handled"; keyboardShouldPersistTaps?: "always" | "never" | "handled";
@@ -19,14 +17,11 @@ export default function ScreenLayout({
children, children,
onScrollBeginDrag, onScrollBeginDrag,
onMomentumScrollEnd, onMomentumScrollEnd,
showHeader = true,
scrollEnabled, scrollEnabled,
keyboardDismissMode, keyboardDismissMode,
keyboardShouldPersistTaps, keyboardShouldPersistTaps,
contentContainerStyle, contentContainerStyle,
}: Props) { }: Props) {
const insets = useSafeAreaInsets();
return ( return (
<LinearGradient <LinearGradient
flex={1} flex={1}
@@ -43,9 +38,8 @@ export default function ScreenLayout({
start={[0, 0]} start={[0, 0]}
end={[1, 1]} end={[1, 1]}
flexGrow={1} flexGrow={1}
paddingTop={showHeader ? insets.top : insets.top + 50}
> >
{showHeader && <Header />} <Header />
<ScrollView <ScrollView
onScrollBeginDrag={onScrollBeginDrag} onScrollBeginDrag={onScrollBeginDrag}
onMomentumScrollEnd={onMomentumScrollEnd} onMomentumScrollEnd={onMomentumScrollEnd}

View File

@@ -4,7 +4,10 @@ import { usePlayerStore } from "~/stores/player/store";
import { BrandPill } from "../BrandPill"; import { BrandPill } from "../BrandPill";
import { BackButton } from "./BackButton"; import { BackButton } from "./BackButton";
import { Controls } from "./Controls"; import { Controls } from "./Controls";
import { mapSeasonAndEpisodeNumberToText } from "./utils";
const mapSeasonAndEpisodeNumberToText = (season: number, episode: number) => {
return `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`;
};
export const Header = () => { export const Header = () => {
const isIdle = usePlayerStore((state) => state.interface.isIdle); const isIdle = usePlayerStore((state) => state.interface.isIdle);

View File

@@ -16,10 +16,3 @@ export const mapMillisecondsToTime = (milliseconds: number): string => {
return formattedTime; return formattedTime;
}; };
export const mapSeasonAndEpisodeNumberToText = (
season: number,
episode: number,
) => {
return `S${season.toString().padStart(2, "0")}E${episode.toString().padStart(2, "0")}`;
};

View File

@@ -1,10 +1,16 @@
import type { DownloadProgressData } from "expo-file-system"; import type { DownloadTask } from "@kesha-antonov/react-native-background-downloader";
import type { Asset } from "expo-media-library"; import type { Asset } from "expo-media-library";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import * as MediaLibrary from "expo-media-library"; import * as MediaLibrary from "expo-media-library";
import * as Network from "expo-network"; import * as Network from "expo-network";
import { NetworkStateType } 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 VideoManager from "@salihgun/react-native-video-processor";
import type { ScrapeMedia } from "@movie-web/provider-utils"; import type { ScrapeMedia } from "@movie-web/provider-utils";
@@ -16,6 +22,11 @@ import {
useNetworkSettingsStore, useNetworkSettingsStore,
} from "~/stores/settings"; } from "~/stores/settings";
interface DownloadProgress {
bytesDownloaded: number;
bytesTotal: number;
}
export interface Download { export interface Download {
id: string; id: string;
progress: number; progress: number;
@@ -33,7 +44,7 @@ export interface Download {
| "importing"; | "importing";
localPath?: string; localPath?: string;
media: ScrapeMedia; media: ScrapeMedia;
downloadTask?: FileSystem.DownloadResumable; downloadTask?: DownloadTask;
} }
export interface DownloadContent { export interface DownloadContent {
@@ -41,6 +52,12 @@ export interface DownloadContent {
downloads: Download[]; downloads: Download[];
} }
// @ts-expect-error - types are not up to date
setConfig({
isLogsEnabled: false,
progressInterval: 250,
});
export const useDownloadManager = () => { export const useDownloadManager = () => {
const cancellationFlags = useState<Record<string, boolean>>({})[0]; const cancellationFlags = useState<Record<string, boolean>>({})[0];
@@ -59,10 +76,10 @@ export const useDownloadManager = () => {
[cancellationFlags], [cancellationFlags],
); );
const cancelDownload = async (download: Download) => { const cancelDownload = (download: Download) => {
setCancellationFlag(download.id, true); setCancellationFlag(download.id, true);
if (download?.downloadTask) { if (download?.downloadTask) {
await download.downloadTask.cancelAsync(); download.downloadTask.stop();
} }
showToast("Download cancelled", { showToast("Download cancelled", {
burntOptions: { preset: "done" }, burntOptions: { preset: "done" },
@@ -124,97 +141,112 @@ export const useDownloadManager = () => {
[setDownloads], [setDownloads],
); );
const saveFileToMediaLibraryAndDeleteOriginal = async ( const saveFileToMediaLibraryAndDeleteOriginal = useCallback(
fileUri: string, async (fileUri: string, download: Download): Promise<Asset | void> => {
download: Download, console.log(
): Promise<Asset | void> => { "Saving file to media library and deleting original",
console.log("Saving file to media library and deleting original", fileUri); fileUri,
try { );
updateDownloadItem(download.id, { status: "importing" }); try {
updateDownloadItem(download.id, { status: "importing" });
const asset = await MediaLibrary.createAssetAsync(fileUri); const asset = await MediaLibrary.createAssetAsync(fileUri);
const { localUri } = await MediaLibrary.getAssetInfoAsync(asset); const { localUri } = await MediaLibrary.getAssetInfoAsync(asset);
await FileSystem.deleteAsync(fileUri); await FileSystem.deleteAsync(fileUri);
updateDownloadItem(download.id, { updateDownloadItem(download.id, {
status: "finished", status: "finished",
localPath: localUri, localPath: localUri,
}); });
console.log("File saved to media library and original deleted"); console.log("File saved to media library and original deleted");
showToast("Download finished", { showToast("Download finished", {
burntOptions: { preset: "done" }, burntOptions: { preset: "done" },
}); });
return asset; return asset;
} catch (error) { } catch (error) {
console.error("Error saving file to media library:", error); console.error("Error saving file to media library:", error);
showToast("Download failed", { showToast("Download failed", {
burntOptions: { preset: "error" }, 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"
? 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); [updateDownloadItem, showToast],
} );
};
const downloadMP4 = useCallback(
(
url: string,
downloadItem: Download,
headers: Record<string, string>,
): Promise<Asset> => {
return new Promise<Asset>((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}`));
});
});
},
[saveFileToMediaLibraryAndDeleteOriginal, updateDownloadItem],
);
const cleanupDownload = useCallback( const cleanupDownload = useCallback(
async (segmentDir: string, download: Download) => { async (segmentDir: string, download: Download) => {
@@ -224,184 +256,202 @@ export const useDownloadManager = () => {
[removeDownload], [removeDownload],
); );
const downloadHLS = async ( const downloadHLS = useCallback(
url: string, async (
download: Download, url: string,
headers: Record<string, string>, download: Download,
) => { headers: Record<string, string>,
const segments = await extractSegmentsFromHLS(url, headers); ) => {
const segments = await extractSegmentsFromHLS(url, headers);
if (!segments || segments.length === 0) { if (!segments || segments.length === 0) {
return removeDownload(download); return removeDownload(download);
} }
const totalSegments = segments.length; const totalSegments = segments.length;
let segmentsDownloaded = 0; let segmentsDownloaded = 0;
const segmentDir = FileSystem.cacheDirectory + "movie-web/segments/"; const segmentDir = FileSystem.cacheDirectory + "movie-web/segments/";
await ensureDirExists(segmentDir); await ensureDirExists(segmentDir);
const updateProgress = () => { const updateProgress = () => {
const progress = segmentsDownloaded / totalSegments; const progress = segmentsDownloaded / totalSegments;
updateDownloadItem(download.id, { updateDownloadItem(download.id, {
progress, progress,
downloaded: segmentsDownloaded, downloaded: segmentsDownloaded,
fileSize: totalSegments, fileSize: totalSegments,
}); });
}; };
const localSegmentPaths = []; 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;
}
}
}
for (const [index, segment] of segments.entries()) {
if (getCancellationFlag(download.id)) { if (getCancellationFlag(download.id)) {
await cleanupDownload(segmentDir, download); 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,
],
);
const startDownload = useCallback(
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();
if (networkType === NetworkStateType.CELLULAR && !allowMobileData) {
showToast("Mobile data downloads are disabled", {
burntOptions: { preset: "error" },
});
return; return;
} }
const segmentFile = `${segmentDir}${index}.ts`; const { status } = await MediaLibrary.requestPermissionsAsync();
localSegmentPaths.push(segmentFile); if (status !== MediaLibrary.PermissionStatus.GRANTED) {
showToast("Permission denied", {
try { burntOptions: { preset: "error" },
await downloadSegment(segment, segmentFile, headers); });
return;
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)) { const existingDownload = downloads.find(
return removeDownload(download); (d) => d.media.tmdbId === media.tmdbId,
}
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 = 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();
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 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 (existingEpisode) { if (existingDownload && media.type === "movie") {
showToast("Download already exists", { showToast("Download already exists", {
burntOptions: { preset: "error" }, burntOptions: { preset: "error" },
}); });
return; return;
} }
}
showToast("Download started", {
burntOptions: { preset: "none" },
});
const newDownload: Download = { if (existingDownload && media.type === "show") {
id: `download-${Date.now()}-${Math.random().toString(16).slice(2)}`, const existingEpisode = existingDownload.downloads.find(
progress: 0, (d) =>
speed: 0, d.media.type === "show" &&
fileSize: 0, d.media.episode.tmdbId === media.episode.tmdbId,
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") { if (existingEpisode) {
const asset = await downloadMP4(url, newDownload, headers ?? {}); showToast("Download already exists", {
return asset; burntOptions: { preset: "error" },
} else if (type === "hls") { });
const asset = await downloadHLS(url, newDownload, headers ?? {}); return;
return asset; }
} }
}; showToast("Download started", {
burntOptions: { preset: "none" },
});
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,
};
const newDownloadContent = existingDownload
? {
...existingDownload,
downloads: [newDownload, ...existingDownload.downloads],
}
: {
media,
downloads: [newDownload],
};
setDownloads((prev) => {
return [...prev, newDownloadContent];
});
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],
);
const downloadSegment = async ( const downloadSegment = async (
downloadId: string,
segmentUrl: string, segmentUrl: string,
segmentFile: string, segmentFile: string,
headers: Record<string, string>, headers: Record<string, string>,
) => { ) => {
const downloadResumable = FileSystem.createDownloadResumable( return new Promise<void>((resolve, reject) => {
segmentUrl, const task = download({
segmentFile, id: `${downloadId}-${segmentUrl.split("/").pop()}`,
{ url: segmentUrl,
headers, destination: segmentFile,
}, headers: headers,
); });
try { task
const result = await downloadResumable.downloadAsync(); .done(() => {
if (result) { resolve();
console.log("Finished downloading to ", result.uri); })
} .error((error) => {
} catch (e) { console.error(error);
console.error(e); reject(error);
} });
});
}; };
async function ensureDirExists(dir: string) { async function ensureDirExists(dir: string) {
@@ -409,6 +459,27 @@ export const useDownloadManager = () => {
await FileSystem.makeDirectoryAsync(dir, { intermediates: true }); 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 { return {
startDownload, startDownload,
removeDownload, removeDownload,

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;

11
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
'@expo/metro-config': '@expo/metro-config':
specifier: ^0.17.3 specifier: ^0.17.3
version: 0.17.3(@react-native/babel-preset@0.73.21) 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': '@movie-web/api':
specifier: '*' specifier: '*'
version: link:../../packages/api version: link:../../packages/api
@@ -2839,6 +2842,14 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/sourcemap-codec': 1.4.15
dev: true 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: /@motionone/animation@10.17.0:
resolution: {integrity: sha512-ANfIN9+iq1kGgsZxs+Nz96uiNcPLGTXwfNo2Xz/fcJXniPYpaz/Uyrfa+7I5BPLxCP82sh7quVDudf1GABqHbg==} resolution: {integrity: sha512-ANfIN9+iq1kGgsZxs+Nz96uiNcPLGTXwfNo2Xz/fcJXniPYpaz/Uyrfa+7I5BPLxCP82sh7quVDudf1GABqHbg==}
dependencies: dependencies: