mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 10:33:26 +00:00
Compare commits
3 Commits
a38ee9ccdb
...
2d62ee0c34
Author | SHA1 | Date | |
---|---|---|---|
|
2d62ee0c34 | ||
|
45d12bbf41 | ||
|
96b00064c6 |
@@ -2,7 +2,6 @@ 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",
|
||||||
@@ -48,7 +47,6 @@ 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",
|
||||||
{
|
{
|
||||||
|
@@ -19,7 +19,6 @@
|
|||||||
},
|
},
|
||||||
"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": "*",
|
||||||
|
61
apps/expo/src/app/(downloads)/[tmdbId].tsx
Normal file
61
apps/expo/src/app/(downloads)/[tmdbId].tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
14
apps/expo/src/app/(downloads)/_layout.tsx
Normal file
14
apps/expo/src/app/(downloads)/_layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
import { BrandPill } from "~/components/BrandPill";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerTransparent: true,
|
||||||
|
headerRight: BrandPill,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@@ -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 } from "~/components/DownloadItem";
|
import { DownloadItem, ShowDownloadItem } 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,15 +15,69 @@ 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 DownloadsScreen: React.FC = () => {
|
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 { 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 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(() => {
|
||||||
@@ -55,85 +109,54 @@ 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 }}>
|
||||||
<MWButton
|
<TestDownloadButton
|
||||||
type="secondary"
|
media={exampleMovieMedia}
|
||||||
backgroundColor="$sheetItemBackground"
|
type="mp4"
|
||||||
icon={
|
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||||
<MaterialCommunityIcons
|
/>
|
||||||
name="download"
|
<TestDownloadButton
|
||||||
size={24}
|
media={getExampleShowMedia(1, 1)}
|
||||||
color={theme.buttonSecondaryText.val}
|
type="mp4"
|
||||||
/>
|
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||||
}
|
/>
|
||||||
onPress={async () => {
|
<TestDownloadButton
|
||||||
await startDownload(
|
media={getExampleShowMedia(1, 2)}
|
||||||
"https://samplelib.com/lib/preview/mp4/sample-5s.mp4",
|
type="mp4"
|
||||||
"mp4",
|
url="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||||
exampleShowMedia,
|
/>
|
||||||
).catch(console.error);
|
<TestDownloadButton
|
||||||
}}
|
media={getExampleShowMedia(1, 1)}
|
||||||
>
|
type="hls"
|
||||||
test download (mp4)
|
url="http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8"
|
||||||
</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",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* TODO: Differentiate movies/shows, shows in new page */}
|
{downloads.map((download) => {
|
||||||
{downloads
|
if (download.downloads.length === 0) return null;
|
||||||
.map((item) => item.downloads)
|
if (download.media.type === "movie") {
|
||||||
.flat()
|
return (
|
||||||
.map((item) => (
|
<DownloadItem
|
||||||
<DownloadItem
|
key={download.media.tmdbId}
|
||||||
key={item.id}
|
item={download.downloads[0]!}
|
||||||
item={item}
|
onPress={() => handlePress(download.downloads[0]!.localPath)}
|
||||||
onPress={() => handlePress(item.localPath)}
|
/>
|
||||||
/>
|
);
|
||||||
))}
|
} else {
|
||||||
|
return (
|
||||||
|
<ShowDownloadItem
|
||||||
|
key={download.media.tmdbId}
|
||||||
|
download={download}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</ScreenLayout>
|
</ScreenLayout>
|
||||||
);
|
);
|
||||||
|
@@ -3,10 +3,12 @@ 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 } from "~/hooks/useDownloadManager";
|
import type { Download, DownloadContent } 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";
|
||||||
|
|
||||||
@@ -101,6 +103,11 @@ 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" && (
|
||||||
@@ -136,3 +143,54 @@ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -124,6 +124,7 @@ 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>
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
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";
|
||||||
@@ -8,20 +7,13 @@ 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
|
<View alignItems="center" gap="$3" flexDirection="row">
|
||||||
paddingTop={insets.top}
|
|
||||||
alignItems="center"
|
|
||||||
gap="$3"
|
|
||||||
flexDirection="row"
|
|
||||||
>
|
|
||||||
<BrandPill />
|
<BrandPill />
|
||||||
|
|
||||||
<Circle
|
<Circle
|
||||||
backgroundColor="$pillBackground"
|
backgroundColor="$pillBackground"
|
||||||
size="$4.5"
|
size="$3.5"
|
||||||
pressStyle={{
|
pressStyle={{
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
scale: 1.05,
|
scale: 1.05,
|
||||||
@@ -33,11 +25,11 @@ export function Header() {
|
|||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="discord" size={32} color="white" />
|
<MaterialIcons name="discord" size={28} color="white" />
|
||||||
</Circle>
|
</Circle>
|
||||||
<Circle
|
<Circle
|
||||||
backgroundColor="$pillBackground"
|
backgroundColor="$pillBackground"
|
||||||
size="$4.5"
|
size="$3.5"
|
||||||
pressStyle={{
|
pressStyle={{
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
scale: 1.05,
|
scale: 1.05,
|
||||||
@@ -49,7 +41,7 @@ export function Header() {
|
|||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FontAwesome6 name="github" size={32} color="white" />
|
<FontAwesome6 name="github" size={28} color="white" />
|
||||||
</Circle>
|
</Circle>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ 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";
|
||||||
@@ -17,11 +19,14 @@ 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}
|
||||||
@@ -38,8 +43,9 @@ 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}
|
||||||
>
|
>
|
||||||
<Header />
|
{showHeader && <Header />}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
onScrollBeginDrag={onScrollBeginDrag}
|
onScrollBeginDrag={onScrollBeginDrag}
|
||||||
onMomentumScrollEnd={onMomentumScrollEnd}
|
onMomentumScrollEnd={onMomentumScrollEnd}
|
||||||
|
@@ -4,10 +4,7 @@ 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);
|
||||||
|
@@ -16,3 +16,10 @@ 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")}`;
|
||||||
|
};
|
||||||
|
@@ -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 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 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";
|
||||||
@@ -22,11 +16,6 @@ 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;
|
||||||
@@ -44,7 +33,7 @@ export interface Download {
|
|||||||
| "importing";
|
| "importing";
|
||||||
localPath?: string;
|
localPath?: string;
|
||||||
media: ScrapeMedia;
|
media: ScrapeMedia;
|
||||||
downloadTask?: DownloadTask;
|
downloadTask?: FileSystem.DownloadResumable;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadContent {
|
export interface DownloadContent {
|
||||||
@@ -52,12 +41,6 @@ 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];
|
||||||
|
|
||||||
@@ -76,10 +59,10 @@ export const useDownloadManager = () => {
|
|||||||
[cancellationFlags],
|
[cancellationFlags],
|
||||||
);
|
);
|
||||||
|
|
||||||
const cancelDownload = (download: Download) => {
|
const cancelDownload = async (download: Download) => {
|
||||||
setCancellationFlag(download.id, true);
|
setCancellationFlag(download.id, true);
|
||||||
if (download?.downloadTask) {
|
if (download?.downloadTask) {
|
||||||
download.downloadTask.stop();
|
await download.downloadTask.cancelAsync();
|
||||||
}
|
}
|
||||||
showToast("Download cancelled", {
|
showToast("Download cancelled", {
|
||||||
burntOptions: { preset: "done" },
|
burntOptions: { preset: "done" },
|
||||||
@@ -141,112 +124,97 @@ export const useDownloadManager = () => {
|
|||||||
[setDownloads],
|
[setDownloads],
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveFileToMediaLibraryAndDeleteOriginal = useCallback(
|
const saveFileToMediaLibraryAndDeleteOriginal = async (
|
||||||
async (fileUri: string, download: Download): Promise<Asset | void> => {
|
fileUri: string,
|
||||||
console.log(
|
download: Download,
|
||||||
"Saving file to media library and deleting original",
|
): Promise<Asset | void> => {
|
||||||
fileUri,
|
console.log("Saving file to media library and deleting original", fileUri);
|
||||||
);
|
try {
|
||||||
try {
|
updateDownloadItem(download.id, { status: "importing" });
|
||||||
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");
|
|
||||||
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<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}`));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
},
|
console.log("File saved to media library and original deleted");
|
||||||
[saveFileToMediaLibraryAndDeleteOriginal, updateDownloadItem],
|
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"
|
||||||
|
? 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(
|
const cleanupDownload = useCallback(
|
||||||
async (segmentDir: string, download: Download) => {
|
async (segmentDir: string, download: Download) => {
|
||||||
@@ -256,202 +224,184 @@ export const useDownloadManager = () => {
|
|||||||
[removeDownload],
|
[removeDownload],
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadHLS = useCallback(
|
const downloadHLS = async (
|
||||||
async (
|
url: string,
|
||||||
url: string,
|
download: Download,
|
||||||
download: Download,
|
headers: Record<string, string>,
|
||||||
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;
|
||||||
|
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;
|
const segmentFile = `${segmentDir}${index}.ts`;
|
||||||
let segmentsDownloaded = 0;
|
localSegmentPaths.push(segmentFile);
|
||||||
|
|
||||||
const segmentDir = FileSystem.cacheDirectory + "movie-web/segments/";
|
try {
|
||||||
await ensureDirExists(segmentDir);
|
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)) {
|
if (getCancellationFlag(download.id)) {
|
||||||
await cleanupDownload(segmentDir, download);
|
await cleanupDownload(segmentDir, download);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const segmentFile = `${segmentDir}${index}.ts`;
|
segmentsDownloaded++;
|
||||||
localSegmentPaths.push(segmentFile);
|
updateProgress();
|
||||||
|
} catch (e) {
|
||||||
try {
|
console.error(e);
|
||||||
await downloadSegment(download.id, segment, segmentFile, headers);
|
if (getCancellationFlag(download.id)) {
|
||||||
|
await cleanupDownload(segmentDir, download);
|
||||||
if (getCancellationFlag(download.id)) {
|
return;
|
||||||
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)) {
|
if (getCancellationFlag(download.id)) {
|
||||||
return removeDownload(download);
|
return removeDownload(download);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDownloadItem(download.id, { status: "merging" });
|
updateDownloadItem(download.id, { status: "merging" });
|
||||||
const uri = await VideoManager.mergeVideos(
|
const uri = await VideoManager.mergeVideos(
|
||||||
localSegmentPaths,
|
localSegmentPaths,
|
||||||
`${FileSystem.cacheDirectory}movie-web/output.mp4`,
|
`${FileSystem.cacheDirectory}movie-web/output.mp4`,
|
||||||
);
|
);
|
||||||
const asset = await saveFileToMediaLibraryAndDeleteOriginal(
|
const asset = await saveFileToMediaLibraryAndDeleteOriginal(uri, download);
|
||||||
uri,
|
return asset;
|
||||||
download,
|
};
|
||||||
);
|
|
||||||
return asset;
|
|
||||||
},
|
|
||||||
[
|
|
||||||
cleanupDownload,
|
|
||||||
getCancellationFlag,
|
|
||||||
removeDownload,
|
|
||||||
saveFileToMediaLibraryAndDeleteOriginal,
|
|
||||||
updateDownloadItem,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const startDownload = useCallback(
|
const startDownload = async (
|
||||||
async (
|
url: string,
|
||||||
url: string,
|
type: "mp4" | "hls",
|
||||||
type: "mp4" | "hls",
|
media: ScrapeMedia,
|
||||||
media: ScrapeMedia,
|
headers?: Record<string, string>,
|
||||||
headers?: Record<string, string>,
|
): Promise<Asset | void> => {
|
||||||
): Promise<Asset | void> => {
|
const { allowMobileData } = useNetworkSettingsStore.getState();
|
||||||
const { allowMobileData } = useNetworkSettingsStore.getState();
|
|
||||||
|
|
||||||
const { type: networkType } = await Network.getNetworkStateAsync();
|
const { type: networkType } = await Network.getNetworkStateAsync();
|
||||||
|
|
||||||
if (networkType === NetworkStateType.CELLULAR && !allowMobileData) {
|
if (networkType === NetworkStateType.CELLULAR && !allowMobileData) {
|
||||||
showToast("Mobile data downloads are disabled", {
|
showToast("Mobile data downloads are disabled", {
|
||||||
burntOptions: { preset: "error" },
|
burntOptions: { preset: "error" },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||||
if (status !== MediaLibrary.PermissionStatus.GRANTED) {
|
if (status !== MediaLibrary.PermissionStatus.GRANTED) {
|
||||||
showToast("Permission denied", {
|
showToast("Permission denied", {
|
||||||
burntOptions: { preset: "error" },
|
burntOptions: { preset: "error" },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingDownload = downloads.find(
|
const existingDownload = downloads.find(
|
||||||
(d) => d.media.tmdbId === media.tmdbId,
|
(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", {
|
showToast("Download already exists", {
|
||||||
burntOptions: { preset: "error" },
|
burntOptions: { preset: "error" },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
showToast("Download started", {
|
||||||
|
burntOptions: { preset: "none" },
|
||||||
|
});
|
||||||
|
|
||||||
if (existingDownload && media.type === "show") {
|
const newDownload: Download = {
|
||||||
const existingEpisode = existingDownload.downloads.find(
|
id: `download-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
(d) =>
|
progress: 0,
|
||||||
d.media.type === "show" &&
|
speed: 0,
|
||||||
d.media.episode.tmdbId === media.episode.tmdbId,
|
fileSize: 0,
|
||||||
);
|
downloaded: 0,
|
||||||
|
type,
|
||||||
if (existingEpisode) {
|
url,
|
||||||
showToast("Download already exists", {
|
status: "downloading",
|
||||||
burntOptions: { preset: "error" },
|
media,
|
||||||
});
|
};
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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],
|
|
||||||
};
|
|
||||||
|
|
||||||
|
if (existingDownload) {
|
||||||
|
existingDownload.downloads.push(newDownload);
|
||||||
setDownloads((prev) => {
|
setDownloads((prev) => {
|
||||||
return [...prev, newDownloadContent];
|
return prev.map((d) =>
|
||||||
|
d.media.tmdbId === media.tmdbId ? existingDownload : d,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
setDownloads((prev) => {
|
||||||
|
return [...prev, { media, downloads: [newDownload] }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "mp4") {
|
if (type === "mp4") {
|
||||||
const asset = await downloadMP4(url, newDownload, headers ?? {});
|
const asset = await downloadMP4(url, newDownload, headers ?? {});
|
||||||
return asset;
|
return asset;
|
||||||
} else if (type === "hls") {
|
} else if (type === "hls") {
|
||||||
const asset = await downloadHLS(url, newDownload, headers ?? {});
|
const asset = await downloadHLS(url, newDownload, headers ?? {});
|
||||||
return asset;
|
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>,
|
||||||
) => {
|
) => {
|
||||||
return new Promise<void>((resolve, reject) => {
|
const downloadResumable = FileSystem.createDownloadResumable(
|
||||||
const task = download({
|
segmentUrl,
|
||||||
id: `${downloadId}-${segmentUrl.split("/").pop()}`,
|
segmentFile,
|
||||||
url: segmentUrl,
|
{
|
||||||
destination: segmentFile,
|
headers,
|
||||||
headers: headers,
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
task
|
try {
|
||||||
.done(() => {
|
const result = await downloadResumable.downloadAsync();
|
||||||
resolve();
|
if (result) {
|
||||||
})
|
console.log("Finished downloading to ", result.uri);
|
||||||
.error((error) => {
|
}
|
||||||
console.error(error);
|
} catch (e) {
|
||||||
reject(error);
|
console.error(e);
|
||||||
});
|
}
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function ensureDirExists(dir: string) {
|
async function ensureDirExists(dir: string) {
|
||||||
@@ -459,27 +409,6 @@ 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,
|
||||||
|
@@ -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 <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
11
pnpm-lock.yaml
generated
@@ -29,9 +29,6 @@ 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
|
||||||
@@ -2842,14 +2839,6 @@ 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:
|
||||||
|
Reference in New Issue
Block a user