mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 16:33:26 +00:00
rework downloads
This commit is contained in:
@@ -10,12 +10,14 @@ import type { ScrapeMedia } from "@movie-web/provider-utils";
|
|||||||
import { DownloadItem } 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 "~/contexts/DownloadManagerContext";
|
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||||
import { PlayerStatus } from "~/stores/player/slices/interface";
|
import { PlayerStatus } from "~/stores/player/slices/interface";
|
||||||
import { usePlayerStore } from "~/stores/player/store";
|
import { usePlayerStore } from "~/stores/player/store";
|
||||||
|
import { useDownloadHistoryStore } from "~/stores/settings";
|
||||||
|
|
||||||
const DownloadsScreen: React.FC = () => {
|
const DownloadsScreen: React.FC = () => {
|
||||||
const { startDownload, downloads } = useDownloadManager();
|
const { startDownload } = useDownloadManager();
|
||||||
|
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);
|
||||||
@@ -106,7 +108,10 @@ const DownloadsScreen: React.FC = () => {
|
|||||||
await startDownload(
|
await startDownload(
|
||||||
"http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8",
|
"http://sample.vodobox.com/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8",
|
||||||
"hls",
|
"hls",
|
||||||
exampleShowMedia,
|
{
|
||||||
|
...exampleShowMedia,
|
||||||
|
tmdbId: "123456",
|
||||||
|
},
|
||||||
).catch(console.error);
|
).catch(console.error);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -118,7 +123,11 @@ const DownloadsScreen: React.FC = () => {
|
|||||||
gap: "$4",
|
gap: "$4",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{downloads.map((item) => (
|
{/* TODO: Differentiate movies/shows, shows in new page */}
|
||||||
|
{downloads
|
||||||
|
.map((item) => item.downloads)
|
||||||
|
.flat()
|
||||||
|
.map((item) => (
|
||||||
<DownloadItem
|
<DownloadItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
@@ -11,7 +11,6 @@ import {
|
|||||||
MaterialCommunityIcons,
|
MaterialCommunityIcons,
|
||||||
MaterialIcons,
|
MaterialIcons,
|
||||||
} from "@expo/vector-icons";
|
} from "@expo/vector-icons";
|
||||||
import { useToastController } from "@tamagui/toast";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Adapt,
|
Adapt,
|
||||||
@@ -32,6 +31,7 @@ import { MWButton } from "~/components/ui/Button";
|
|||||||
import { MWSelect } from "~/components/ui/Select";
|
import { MWSelect } from "~/components/ui/Select";
|
||||||
import { MWSeparator } from "~/components/ui/Separator";
|
import { MWSeparator } from "~/components/ui/Separator";
|
||||||
import { MWSwitch } from "~/components/ui/Switch";
|
import { MWSwitch } from "~/components/ui/Switch";
|
||||||
|
import { useToast } from "~/hooks/useToast";
|
||||||
import { checkForUpdate } from "~/lib/update";
|
import { checkForUpdate } from "~/lib/update";
|
||||||
import {
|
import {
|
||||||
useNetworkSettingsStore,
|
useNetworkSettingsStore,
|
||||||
@@ -54,10 +54,10 @@ export default function SettingsScreen() {
|
|||||||
const { gestureControls, setGestureControls, autoPlay, setAutoPlay } =
|
const { gestureControls, setGestureControls, autoPlay, setAutoPlay } =
|
||||||
usePlayerSettingsStore();
|
usePlayerSettingsStore();
|
||||||
const { allowMobileData, setAllowMobileData } = useNetworkSettingsStore();
|
const { allowMobileData, setAllowMobileData } = useNetworkSettingsStore();
|
||||||
const toastController = useToastController();
|
|
||||||
const [showUpdateSheet, setShowUpdateSheet] = useState(false);
|
const [showUpdateSheet, setShowUpdateSheet] = useState(false);
|
||||||
const [updateMarkdownContent, setUpdateMarkdownContent] = useState("");
|
const [updateMarkdownContent, setUpdateMarkdownContent] = useState("");
|
||||||
const [downloadUrl, setDownloadUrl] = useState("");
|
const [downloadUrl, setDownloadUrl] = useState("");
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationKey: ["checkForUpdate"],
|
mutationKey: ["checkForUpdate"],
|
||||||
@@ -74,11 +74,7 @@ export default function SettingsScreen() {
|
|||||||
);
|
);
|
||||||
setShowUpdateSheet(true);
|
setShowUpdateSheet(true);
|
||||||
} else {
|
} else {
|
||||||
toastController.show("No updates available", {
|
showToast("No updates available");
|
||||||
burntOptions: { preset: "none" },
|
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -100,18 +96,13 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await FileSystem.deleteAsync(cacheDirectory, { idempotent: true });
|
await FileSystem.deleteAsync(cacheDirectory, { idempotent: true });
|
||||||
|
showToast("Cache cleared", {
|
||||||
toastController.show("Cache cleared", {
|
|
||||||
burntOptions: { preset: "done" },
|
burntOptions: { preset: "done" },
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error clearing cache directory:", error);
|
console.error("Error clearing cache directory:", error);
|
||||||
toastController.show("Error clearing cache", {
|
showToast("Error clearing cache", {
|
||||||
burntOptions: { preset: "error" },
|
burntOptions: { preset: "error" },
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@@ -10,7 +10,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { TamaguiProvider, Theme, useTheme } from "tamagui";
|
import { TamaguiProvider, Theme, useTheme } from "tamagui";
|
||||||
import tamaguiConfig from "tamagui.config";
|
import tamaguiConfig from "tamagui.config";
|
||||||
|
|
||||||
import { DownloadManagerProvider } from "~/contexts/DownloadManagerContext";
|
|
||||||
import { useThemeStore } from "~/stores/theme";
|
import { useThemeStore } from "~/stores/theme";
|
||||||
// @ts-expect-error - Without named import it causes an infinite loop
|
// @ts-expect-error - Without named import it causes an infinite loop
|
||||||
import _styles from "../../tamagui-web.css";
|
import _styles from "../../tamagui-web.css";
|
||||||
@@ -106,13 +105,11 @@ function RootLayoutNav() {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<TamaguiProvider config={tamaguiConfig} defaultTheme="main">
|
<TamaguiProvider config={tamaguiConfig} defaultTheme="main">
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<DownloadManagerProvider>
|
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Theme name={themeStore}>
|
<Theme name={themeStore}>
|
||||||
<ScreenStacks />
|
<ScreenStacks />
|
||||||
</Theme>
|
</Theme>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</DownloadManagerProvider>
|
|
||||||
<ToastViewport />
|
<ToastViewport />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</TamaguiProvider>
|
</TamaguiProvider>
|
||||||
|
@@ -5,8 +5,8 @@ import ContextMenu from "react-native-context-menu-view";
|
|||||||
import { TouchableOpacity } from "react-native-gesture-handler";
|
import { TouchableOpacity } from "react-native-gesture-handler";
|
||||||
import { Image, Text, View, XStack, YStack } from "tamagui";
|
import { Image, Text, View, XStack, YStack } from "tamagui";
|
||||||
|
|
||||||
import type { Download } from "~/contexts/DownloadManagerContext";
|
import type { Download } from "~/hooks/useDownloadManager";
|
||||||
import { useDownloadManager } from "~/contexts/DownloadManagerContext";
|
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||||
import { MWProgress } from "./ui/Progress";
|
import { MWProgress } from "./ui/Progress";
|
||||||
import { FlashingText } from "./ui/Text";
|
import { FlashingText } from "./ui/Text";
|
||||||
|
|
||||||
@@ -57,9 +57,9 @@ export function DownloadItem(props: DownloadItemProps) {
|
|||||||
e: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>,
|
e: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>,
|
||||||
) => {
|
) => {
|
||||||
if (e.nativeEvent.name === ContextMenuActions.Cancel) {
|
if (e.nativeEvent.name === ContextMenuActions.Cancel) {
|
||||||
void cancelDownload(props.item.id);
|
void cancelDownload(props.item);
|
||||||
} else if (e.nativeEvent.name === ContextMenuActions.Remove) {
|
} else if (e.nativeEvent.name === ContextMenuActions.Remove) {
|
||||||
removeDownload(props.item.id);
|
removeDownload(props.item);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -4,9 +4,9 @@ import { useCallback } from "react";
|
|||||||
import { Keyboard, TouchableOpacity } from "react-native";
|
import { Keyboard, TouchableOpacity } from "react-native";
|
||||||
import ContextMenu from "react-native-context-menu-view";
|
import ContextMenu from "react-native-context-menu-view";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useToastController } from "@tamagui/toast";
|
|
||||||
import { Image, Text, View } from "tamagui";
|
import { Image, Text, View } from "tamagui";
|
||||||
|
|
||||||
|
import { useToast } from "~/hooks/useToast";
|
||||||
import { usePlayerStore } from "~/stores/player/store";
|
import { usePlayerStore } from "~/stores/player/store";
|
||||||
import { useBookmarkStore, useWatchHistoryStore } from "~/stores/settings";
|
import { useBookmarkStore, useWatchHistoryStore } from "~/stores/settings";
|
||||||
|
|
||||||
@@ -43,10 +43,10 @@ function checkReleased(media: ItemData): boolean {
|
|||||||
export default function Item({ data }: { data: ItemData }) {
|
export default function Item({ data }: { data: ItemData }) {
|
||||||
const resetVideo = usePlayerStore((state) => state.resetVideo);
|
const resetVideo = usePlayerStore((state) => state.resetVideo);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const toastController = useToastController();
|
|
||||||
const { isBookmarked, addBookmark, removeBookmark } = useBookmarkStore();
|
const { isBookmarked, addBookmark, removeBookmark } = useBookmarkStore();
|
||||||
const { hasWatchHistoryItem, removeFromWatchHistory } =
|
const { hasWatchHistoryItem, removeFromWatchHistory } =
|
||||||
useWatchHistoryStore();
|
useWatchHistoryStore();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
const { title, type, year, posterUrl } = data;
|
const { title, type, year, posterUrl } = data;
|
||||||
|
|
||||||
@@ -54,10 +54,8 @@ export default function Item({ data }: { data: ItemData }) {
|
|||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
if (!isReleased()) {
|
if (!isReleased()) {
|
||||||
toastController.show("This media is not released yet", {
|
showToast("This media is not released yet", {
|
||||||
burntOptions: { preset: "error" },
|
burntOptions: { preset: "error" },
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -86,17 +84,13 @@ export default function Item({ data }: { data: ItemData }) {
|
|||||||
) => {
|
) => {
|
||||||
if (e.nativeEvent.name === ContextMenuActions.Bookmark) {
|
if (e.nativeEvent.name === ContextMenuActions.Bookmark) {
|
||||||
addBookmark(data);
|
addBookmark(data);
|
||||||
toastController.show("Added to bookmarks", {
|
showToast("Added to bookmarks", {
|
||||||
burntOptions: { preset: "done" },
|
burntOptions: { preset: "done" },
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
});
|
||||||
} else if (e.nativeEvent.name === ContextMenuActions.RemoveBookmark) {
|
} else if (e.nativeEvent.name === ContextMenuActions.RemoveBookmark) {
|
||||||
removeBookmark(data);
|
removeBookmark(data);
|
||||||
toastController.show("Removed from bookmarks", {
|
showToast("Removed from bookmarks", {
|
||||||
burntOptions: { preset: "done" },
|
burntOptions: { preset: "done" },
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
});
|
||||||
} else if (e.nativeEvent.name === ContextMenuActions.Download) {
|
} else if (e.nativeEvent.name === ContextMenuActions.Download) {
|
||||||
router.push({
|
router.push({
|
||||||
@@ -107,10 +101,8 @@ export default function Item({ data }: { data: ItemData }) {
|
|||||||
e.nativeEvent.name === ContextMenuActions.RemoveWatchHistoryItem
|
e.nativeEvent.name === ContextMenuActions.RemoveWatchHistoryItem
|
||||||
) {
|
) {
|
||||||
removeFromWatchHistory(data);
|
removeFromWatchHistory(data);
|
||||||
toastController.show("Removed from Continue Watching", {
|
showToast("Removed from Continue Watching", {
|
||||||
burntOptions: { preset: "done" },
|
burntOptions: { preset: "done" },
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@@ -2,7 +2,6 @@ import type { LanguageCode } from "iso-639-1";
|
|||||||
import type { ContentCaption } from "subsrt-ts/dist/types/handler";
|
import type { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
|
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
|
||||||
import { useToastController } from "@tamagui/toast";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { parse } from "subsrt-ts";
|
import { parse } from "subsrt-ts";
|
||||||
import { Spinner, useTheme, View } from "tamagui";
|
import { Spinner, useTheme, View } from "tamagui";
|
||||||
@@ -10,6 +9,7 @@ import { Spinner, useTheme, View } from "tamagui";
|
|||||||
import type { Stream } from "@movie-web/provider-utils";
|
import type { Stream } from "@movie-web/provider-utils";
|
||||||
|
|
||||||
import type { CaptionWithData } from "~/stores/captions";
|
import type { CaptionWithData } from "~/stores/captions";
|
||||||
|
import { useToast } from "~/hooks/useToast";
|
||||||
import {
|
import {
|
||||||
getCountryCodeFromLanguage,
|
getCountryCodeFromLanguage,
|
||||||
getPrettyLanguageNameFromLocale,
|
getPrettyLanguageNameFromLocale,
|
||||||
@@ -35,7 +35,7 @@ const parseCaption = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CaptionsSelector = () => {
|
export const CaptionsSelector = () => {
|
||||||
const toast = useToastController();
|
const { showToast } = useToast();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const captions = usePlayerStore(
|
const captions = usePlayerStore(
|
||||||
@@ -97,11 +97,7 @@ export const CaptionsSelector = () => {
|
|||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
chromeless
|
chromeless
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
toast.show("Work in progress", {
|
showToast("Work in progress");
|
||||||
burntOptions: { preset: "none" },
|
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Customize
|
Customize
|
||||||
|
@@ -3,7 +3,7 @@ import { useTheme } from "tamagui";
|
|||||||
|
|
||||||
import { findHighestQuality } from "@movie-web/provider-utils";
|
import { findHighestQuality } from "@movie-web/provider-utils";
|
||||||
|
|
||||||
import { useDownloadManager } from "~/contexts/DownloadManagerContext";
|
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||||
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
||||||
import { usePlayerStore } from "~/stores/player/store";
|
import { usePlayerStore } from "~/stores/player/store";
|
||||||
import { MWButton } from "../ui/Button";
|
import { MWButton } from "../ui/Button";
|
||||||
|
@@ -18,9 +18,9 @@ import {
|
|||||||
import type { ItemData } from "../item/item";
|
import type { ItemData } from "../item/item";
|
||||||
import type { AudioTrack } from "./AudioTrackSelector";
|
import type { AudioTrack } from "./AudioTrackSelector";
|
||||||
import type { PlayerMeta } from "~/stores/player/slices/video";
|
import type { PlayerMeta } from "~/stores/player/slices/video";
|
||||||
import { useDownloadManager } from "~/contexts/DownloadManagerContext";
|
|
||||||
import { useMeta } from "~/hooks/player/useMeta";
|
import { useMeta } from "~/hooks/player/useMeta";
|
||||||
import { useScrape } from "~/hooks/player/useSourceScrape";
|
import { useScrape } from "~/hooks/player/useSourceScrape";
|
||||||
|
import { useDownloadManager } from "~/hooks/useDownloadManager";
|
||||||
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
import { convertMetaToScrapeMedia } from "~/lib/meta";
|
||||||
import { PlayerStatus } from "~/stores/player/slices/interface";
|
import { PlayerStatus } from "~/stores/player/slices/interface";
|
||||||
import { usePlayerStore } from "~/stores/player/store";
|
import { usePlayerStore } from "~/stores/player/store";
|
||||||
|
@@ -1,442 +0,0 @@
|
|||||||
import type { DownloadTask } from "@kesha-antonov/react-native-background-downloader";
|
|
||||||
import type { Asset } from "expo-media-library";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import * as MediaLibrary from "expo-media-library";
|
|
||||||
import * as Network 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 { useToastController } from "@tamagui/toast";
|
|
||||||
|
|
||||||
import type { ScrapeMedia } from "@movie-web/provider-utils";
|
|
||||||
import { extractSegmentsFromHLS } from "@movie-web/provider-utils";
|
|
||||||
|
|
||||||
import {
|
|
||||||
useDownloadHistoryStore,
|
|
||||||
useNetworkSettingsStore,
|
|
||||||
} from "~/stores/settings";
|
|
||||||
|
|
||||||
export interface Download {
|
|
||||||
id: string;
|
|
||||||
progress: number;
|
|
||||||
speed: number;
|
|
||||||
fileSize: number;
|
|
||||||
downloaded: number;
|
|
||||||
url: string;
|
|
||||||
type: "mp4" | "hls";
|
|
||||||
status:
|
|
||||||
| "downloading"
|
|
||||||
| "finished"
|
|
||||||
| "error"
|
|
||||||
| "merging"
|
|
||||||
| "cancelled"
|
|
||||||
| "importing";
|
|
||||||
localPath?: string;
|
|
||||||
media: ScrapeMedia;
|
|
||||||
downloadTask?: DownloadTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DownloadContent {
|
|
||||||
media: Pick<ScrapeMedia, "title" | "releaseYear" | "type" | "tmdbId">;
|
|
||||||
downloads: Download[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-expect-error - types are not up to date
|
|
||||||
setConfig({
|
|
||||||
isLogsEnabled: false,
|
|
||||||
progressInterval: 250,
|
|
||||||
});
|
|
||||||
|
|
||||||
interface DownloadManagerContextType {
|
|
||||||
downloads: Download[];
|
|
||||||
startDownload: (
|
|
||||||
url: string,
|
|
||||||
type: "mp4" | "hls",
|
|
||||||
media: ScrapeMedia,
|
|
||||||
headers?: Record<string, string>,
|
|
||||||
) => Promise<Asset | void>;
|
|
||||||
removeDownload: (id: string) => void;
|
|
||||||
cancelDownload: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DownloadManagerContext = createContext<
|
|
||||||
DownloadManagerContextType | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
export const useDownloadManager = () => {
|
|
||||||
const context = useContext(DownloadManagerContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"useDownloadManager must be used within a DownloadManagerProvider",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const [downloads, setDownloads] = useState<Download[]>([]);
|
|
||||||
const toastController = useToastController();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initializeDownloads = () => {
|
|
||||||
const { downloads } = useDownloadHistoryStore.getState();
|
|
||||||
if (downloads) {
|
|
||||||
setDownloads(downloads);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void initializeDownloads();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
useDownloadHistoryStore.setState({ downloads });
|
|
||||||
}, [downloads]);
|
|
||||||
|
|
||||||
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();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cancellationFlags = useState<Record<string, boolean>>({})[0];
|
|
||||||
|
|
||||||
const setCancellationFlag = (downloadId: string, flag: boolean): void => {
|
|
||||||
cancellationFlags[downloadId] = flag;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCancellationFlag = (downloadId: string): boolean => {
|
|
||||||
return cancellationFlags[downloadId] ?? false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelDownload = (downloadId: string) => {
|
|
||||||
setCancellationFlag(downloadId, true);
|
|
||||||
const downloadItem = downloads.find((d) => d.id === downloadId);
|
|
||||||
if (downloadItem?.downloadTask) {
|
|
||||||
downloadItem.downloadTask.stop();
|
|
||||||
}
|
|
||||||
toastController.show("Download cancelled", {
|
|
||||||
burntOptions: { preset: "done" },
|
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const startDownload = async (
|
|
||||||
url: string,
|
|
||||||
type: "mp4" | "hls",
|
|
||||||
media: ScrapeMedia,
|
|
||||||
headers?: Record<string, string>,
|
|
||||||
): Promise<Asset | void> => {
|
|
||||||
const { allowMobileData } = useNetworkSettingsStore.getState();
|
|
||||||
|
|
||||||
const { type: networkType } = await Network.getNetworkStateAsync();
|
|
||||||
|
|
||||||
if (networkType === NetworkStateType.CELLULAR && !allowMobileData) {
|
|
||||||
toastController.show("Mobile data downloads are disabled", {
|
|
||||||
burntOptions: { preset: "error" },
|
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
|
||||||
if (status !== MediaLibrary.PermissionStatus.GRANTED) {
|
|
||||||
toastController.show("Permission denied", {
|
|
||||||
burntOptions: { preset: "error" },
|
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toastController.show("Download started", {
|
|
||||||
burntOptions: { preset: "none" },
|
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
setDownloads((currentDownloads) => [newDownload, ...currentDownloads]);
|
|
||||||
|
|
||||||
if (type === "mp4") {
|
|
||||||
const asset = await downloadMP4(url, newDownload.id, headers ?? {});
|
|
||||||
return asset;
|
|
||||||
} else if (type === "hls") {
|
|
||||||
const asset = await downloadHLS(url, newDownload.id, headers ?? {});
|
|
||||||
return asset;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateDownloadItem = (id: string, updates: Partial<Download>) => {
|
|
||||||
setDownloads((currentDownloads) =>
|
|
||||||
currentDownloads.map((download) =>
|
|
||||||
download.id === id ? { ...download, ...updates } : download,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DownloadProgress {
|
|
||||||
bytesDownloaded: number;
|
|
||||||
bytesTotal: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadMP4 = (
|
|
||||||
url: string,
|
|
||||||
downloadId: string,
|
|
||||||
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(downloadId, {
|
|
||||||
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: downloadId,
|
|
||||||
url,
|
|
||||||
destination: fileUri,
|
|
||||||
headers,
|
|
||||||
isNotificationVisible: true,
|
|
||||||
})
|
|
||||||
.begin(() => {
|
|
||||||
updateDownloadItem(downloadId, { downloadTask });
|
|
||||||
})
|
|
||||||
.progress(({ bytesDownloaded, bytesTotal }) => {
|
|
||||||
updateProgress({ bytesDownloaded, bytesTotal });
|
|
||||||
})
|
|
||||||
.done(() => {
|
|
||||||
saveFileToMediaLibraryAndDeleteOriginal(fileUri, downloadId)
|
|
||||||
.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}`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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 + "movie-web/segments/";
|
|
||||||
await ensureDirExists(segmentDir);
|
|
||||||
|
|
||||||
const updateProgress = () => {
|
|
||||||
const progress = segmentsDownloaded / totalSegments;
|
|
||||||
updateDownloadItem(downloadId, {
|
|
||||||
progress,
|
|
||||||
downloaded: segmentsDownloaded,
|
|
||||||
fileSize: totalSegments,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const localSegmentPaths = [];
|
|
||||||
|
|
||||||
for (const [index, segment] of segments.entries()) {
|
|
||||||
if (getCancellationFlag(downloadId)) {
|
|
||||||
await cleanupDownload(segmentDir, downloadId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const segmentFile = `${segmentDir}${index}.ts`;
|
|
||||||
localSegmentPaths.push(segmentFile);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await downloadSegment(downloadId, segment, segmentFile, headers);
|
|
||||||
|
|
||||||
if (getCancellationFlag(downloadId)) {
|
|
||||||
await cleanupDownload(segmentDir, downloadId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
segmentsDownloaded++;
|
|
||||||
updateProgress();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
if (getCancellationFlag(downloadId)) {
|
|
||||||
await cleanupDownload(segmentDir, downloadId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (getCancellationFlag(downloadId)) {
|
|
||||||
return removeDownload(downloadId);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDownloadItem(downloadId, { status: "merging" });
|
|
||||||
const uri = await VideoManager.mergeVideos(
|
|
||||||
localSegmentPaths,
|
|
||||||
`${FileSystem.cacheDirectory}movie-web/output.mp4`,
|
|
||||||
);
|
|
||||||
const asset = await saveFileToMediaLibraryAndDeleteOriginal(
|
|
||||||
uri,
|
|
||||||
downloadId,
|
|
||||||
);
|
|
||||||
return asset;
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadSegment = async (
|
|
||||||
downloadId: string,
|
|
||||||
segmentUrl: string,
|
|
||||||
segmentFile: string,
|
|
||||||
headers: Record<string, string>,
|
|
||||||
) => {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const task = download({
|
|
||||||
id: `${downloadId}-${segmentUrl.split("/").pop()}`,
|
|
||||||
url: segmentUrl,
|
|
||||||
destination: segmentFile,
|
|
||||||
headers: headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
task
|
|
||||||
.done(() => {
|
|
||||||
resolve();
|
|
||||||
})
|
|
||||||
.error((error) => {
|
|
||||||
console.error(error);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanupDownload = async (segmentDir: string, downloadId: string) => {
|
|
||||||
await FileSystem.deleteAsync(segmentDir, { idempotent: true });
|
|
||||||
removeDownload(downloadId);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function ensureDirExists(dir: string) {
|
|
||||||
await FileSystem.deleteAsync(dir, { idempotent: true });
|
|
||||||
await FileSystem.makeDirectoryAsync(dir, { intermediates: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveFileToMediaLibraryAndDeleteOriginal = async (
|
|
||||||
fileUri: string,
|
|
||||||
downloadId: string,
|
|
||||||
): Promise<Asset | void> => {
|
|
||||||
try {
|
|
||||||
updateDownloadItem(downloadId, { status: "importing" });
|
|
||||||
|
|
||||||
const asset = await MediaLibrary.createAssetAsync(fileUri);
|
|
||||||
await FileSystem.deleteAsync(fileUri);
|
|
||||||
|
|
||||||
updateDownloadItem(downloadId, {
|
|
||||||
status: "finished",
|
|
||||||
localPath: asset.uri,
|
|
||||||
});
|
|
||||||
console.log("File saved to media library and original deleted");
|
|
||||||
toastController.show("Download finished", {
|
|
||||||
burntOptions: { preset: "done" },
|
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
|
||||||
return asset;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error saving file to media library:", error);
|
|
||||||
toastController.show("Download failed", {
|
|
||||||
burntOptions: { preset: "error" },
|
|
||||||
native: true,
|
|
||||||
duration: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeDownload = (id: string) => {
|
|
||||||
const updatedDownloads = downloads.filter((download) => download.id !== id);
|
|
||||||
setDownloads(updatedDownloads);
|
|
||||||
useDownloadHistoryStore.setState({ downloads: updatedDownloads });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DownloadManagerContext.Provider
|
|
||||||
value={{ downloads, startDownload, removeDownload, cancelDownload }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DownloadManagerContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
487
apps/expo/src/hooks/useDownloadManager.tsx
Normal file
487
apps/expo/src/hooks/useDownloadManager.tsx
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
import type { DownloadTask } from "@kesha-antonov/react-native-background-downloader";
|
||||||
|
import type { Asset } from "expo-media-library";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import * as MediaLibrary from "expo-media-library";
|
||||||
|
import * as Network 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 type { ScrapeMedia } from "@movie-web/provider-utils";
|
||||||
|
import { extractSegmentsFromHLS } from "@movie-web/provider-utils";
|
||||||
|
|
||||||
|
import { useToast } from "~/hooks/useToast";
|
||||||
|
import {
|
||||||
|
useDownloadHistoryStore,
|
||||||
|
useNetworkSettingsStore,
|
||||||
|
} from "~/stores/settings";
|
||||||
|
|
||||||
|
interface DownloadProgress {
|
||||||
|
bytesDownloaded: number;
|
||||||
|
bytesTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Download {
|
||||||
|
id: string;
|
||||||
|
progress: number;
|
||||||
|
speed: number;
|
||||||
|
fileSize: number;
|
||||||
|
downloaded: number;
|
||||||
|
url: string;
|
||||||
|
type: "mp4" | "hls";
|
||||||
|
status:
|
||||||
|
| "downloading"
|
||||||
|
| "finished"
|
||||||
|
| "error"
|
||||||
|
| "merging"
|
||||||
|
| "cancelled"
|
||||||
|
| "importing";
|
||||||
|
localPath?: string;
|
||||||
|
media: ScrapeMedia;
|
||||||
|
downloadTask?: DownloadTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadContent {
|
||||||
|
media: Pick<ScrapeMedia, "title" | "releaseYear" | "type" | "tmdbId">;
|
||||||
|
downloads: Download[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-expect-error - types are not up to date
|
||||||
|
setConfig({
|
||||||
|
isLogsEnabled: false,
|
||||||
|
progressInterval: 250,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useDownloadManager = () => {
|
||||||
|
const cancellationFlags = useState<Record<string, boolean>>({})[0];
|
||||||
|
|
||||||
|
const downloads = useDownloadHistoryStore((state) => state.downloads);
|
||||||
|
const setDownloads = useDownloadHistoryStore((state) => state.setDownloads);
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const setCancellationFlag = (downloadId: string, flag: boolean): void => {
|
||||||
|
cancellationFlags[downloadId] = flag;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCancellationFlag = useCallback(
|
||||||
|
(downloadId: string): boolean => {
|
||||||
|
return cancellationFlags[downloadId] ?? false;
|
||||||
|
},
|
||||||
|
[cancellationFlags],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelDownload = (download: Download) => {
|
||||||
|
setCancellationFlag(download.id, true);
|
||||||
|
if (download?.downloadTask) {
|
||||||
|
download.downloadTask.stop();
|
||||||
|
}
|
||||||
|
showToast("Download cancelled", {
|
||||||
|
burntOptions: { preset: "done" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDownloadItem = useCallback(
|
||||||
|
(downloadId: string, download: Partial<Download>) => {
|
||||||
|
setDownloads((prev) => {
|
||||||
|
const updatedDownloads = prev.map((content) => {
|
||||||
|
const updatedDownloadsArray = content.downloads.map((d) =>
|
||||||
|
d.id === downloadId
|
||||||
|
? {
|
||||||
|
...d,
|
||||||
|
...download,
|
||||||
|
}
|
||||||
|
: d,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...content,
|
||||||
|
downloads: updatedDownloadsArray,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedDownloads;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setDownloads],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeDownload = useCallback(
|
||||||
|
(download: Download) => {
|
||||||
|
if (download.media.type === "movie") {
|
||||||
|
setDownloads((prev) =>
|
||||||
|
prev.filter((d) => d.media.tmdbId !== download.media.tmdbId),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else if (download.media.type === "show") {
|
||||||
|
setDownloads((prev) => {
|
||||||
|
const existingDownload = prev.find(
|
||||||
|
(d) => d.media.tmdbId === download.media.tmdbId,
|
||||||
|
);
|
||||||
|
if (existingDownload?.downloads.length === 1) {
|
||||||
|
return prev.filter((d) => d.media.tmdbId !== download.media.tmdbId);
|
||||||
|
} else {
|
||||||
|
return prev.map((content) => {
|
||||||
|
return {
|
||||||
|
...content,
|
||||||
|
downloads: content.downloads.filter(
|
||||||
|
(d) => d.id !== download.id,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setDownloads],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveFileToMediaLibraryAndDeleteOriginal = useCallback(
|
||||||
|
async (fileUri: string, download: Download): Promise<Asset | void> => {
|
||||||
|
console.log(
|
||||||
|
"Saving file to media library and deleting original",
|
||||||
|
fileUri,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
updateDownloadItem(download.id, { status: "importing" });
|
||||||
|
|
||||||
|
const asset = await MediaLibrary.createAssetAsync(fileUri);
|
||||||
|
await FileSystem.deleteAsync(fileUri);
|
||||||
|
|
||||||
|
updateDownloadItem(download.id, {
|
||||||
|
status: "finished",
|
||||||
|
localPath: asset.uri,
|
||||||
|
});
|
||||||
|
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}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[saveFileToMediaLibraryAndDeleteOriginal, updateDownloadItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleanupDownload = useCallback(
|
||||||
|
async (segmentDir: string, download: Download) => {
|
||||||
|
await FileSystem.deleteAsync(segmentDir, { idempotent: true });
|
||||||
|
removeDownload(download);
|
||||||
|
},
|
||||||
|
[removeDownload],
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadHLS = useCallback(
|
||||||
|
async (
|
||||||
|
url: string,
|
||||||
|
download: Download,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
) => {
|
||||||
|
const segments = await extractSegmentsFromHLS(url, headers);
|
||||||
|
|
||||||
|
if (!segments || segments.length === 0) {
|
||||||
|
return removeDownload(download);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSegments = segments.length;
|
||||||
|
let segmentsDownloaded = 0;
|
||||||
|
|
||||||
|
const segmentDir = FileSystem.cacheDirectory + "movie-web/segments/";
|
||||||
|
await ensureDirExists(segmentDir);
|
||||||
|
|
||||||
|
const updateProgress = () => {
|
||||||
|
const progress = segmentsDownloaded / totalSegments;
|
||||||
|
updateDownloadItem(download.id, {
|
||||||
|
progress,
|
||||||
|
downloaded: segmentsDownloaded,
|
||||||
|
fileSize: totalSegments,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const localSegmentPaths = [];
|
||||||
|
|
||||||
|
for (const [index, segment] of segments.entries()) {
|
||||||
|
if (getCancellationFlag(download.id)) {
|
||||||
|
await cleanupDownload(segmentDir, download);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getCancellationFlag(download.id)) {
|
||||||
|
return removeDownload(download);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDownloadItem(download.id, { status: "merging" });
|
||||||
|
const uri = await VideoManager.mergeVideos(
|
||||||
|
localSegmentPaths,
|
||||||
|
`${FileSystem.cacheDirectory}movie-web/output.mp4`,
|
||||||
|
);
|
||||||
|
const asset = await saveFileToMediaLibraryAndDeleteOriginal(
|
||||||
|
uri,
|
||||||
|
download,
|
||||||
|
);
|
||||||
|
return asset;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
showToast("Download already exists", {
|
||||||
|
burntOptions: { preset: "error" },
|
||||||
|
});
|
||||||
|
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],
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
downloadId: string,
|
||||||
|
segmentUrl: string,
|
||||||
|
segmentFile: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const task = download({
|
||||||
|
id: `${downloadId}-${segmentUrl.split("/").pop()}`,
|
||||||
|
url: segmentUrl,
|
||||||
|
destination: segmentFile,
|
||||||
|
headers: headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
task
|
||||||
|
.done(() => {
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
.error((error) => {
|
||||||
|
console.error(error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function ensureDirExists(dir: string) {
|
||||||
|
await FileSystem.deleteAsync(dir, { idempotent: 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 {
|
||||||
|
startDownload,
|
||||||
|
removeDownload,
|
||||||
|
cancelDownload,
|
||||||
|
};
|
||||||
|
};
|
22
apps/expo/src/hooks/useToast.ts
Normal file
22
apps/expo/src/hooks/useToast.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useToastController } from "@tamagui/toast";
|
||||||
|
|
||||||
|
type ShowOptions = Parameters<ReturnType<typeof useToastController>["show"]>[1];
|
||||||
|
|
||||||
|
export const useToast = () => {
|
||||||
|
const toastController = useToastController();
|
||||||
|
|
||||||
|
const showToast = useCallback(
|
||||||
|
(title: string, options?: ShowOptions) => {
|
||||||
|
toastController.show(title, {
|
||||||
|
burntOptions: { preset: "none" },
|
||||||
|
native: true,
|
||||||
|
duration: 500,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[toastController],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { showToast };
|
||||||
|
};
|
1
apps/expo/src/stores/index.ts
Normal file
1
apps/expo/src/stores/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type ReactStyleStateSetter<T> = T | ((prev: T) => T);
|
@@ -6,8 +6,9 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
|||||||
|
|
||||||
import type { ScrapeMedia } from "@movie-web/provider-utils";
|
import type { ScrapeMedia } from "@movie-web/provider-utils";
|
||||||
|
|
||||||
|
import type { ReactStyleStateSetter } from "..";
|
||||||
import type { ItemData } from "~/components/item/item";
|
import type { ItemData } from "~/components/item/item";
|
||||||
import type { Download } from "~/contexts/DownloadManagerContext";
|
import type { DownloadContent } from "~/hooks/useDownloadManager";
|
||||||
import type { ThemeStoreOption } from "~/stores/theme";
|
import type { ThemeStoreOption } from "~/stores/theme";
|
||||||
|
|
||||||
const storage = new MMKV();
|
const storage = new MMKV();
|
||||||
@@ -37,7 +38,7 @@ export const useThemeSettingsStore = create<
|
|||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
theme: "main",
|
theme: "main",
|
||||||
setTheme: (theme: ThemeStoreOption) => set({ theme }),
|
setTheme: (theme) => set({ theme }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "theme-settings",
|
name: "theme-settings",
|
||||||
@@ -64,10 +65,9 @@ export const usePlayerSettingsStore = create<
|
|||||||
android: false,
|
android: false,
|
||||||
default: true,
|
default: true,
|
||||||
}),
|
}),
|
||||||
setGestureControls: (enabled: boolean) =>
|
setGestureControls: (enabled) => set({ gestureControls: enabled }),
|
||||||
set({ gestureControls: enabled }),
|
|
||||||
autoPlay: true,
|
autoPlay: true,
|
||||||
setAutoPlay: (enabled: boolean) => set({ autoPlay: enabled }),
|
setAutoPlay: (enabled) => set({ autoPlay: enabled }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "player-settings",
|
name: "player-settings",
|
||||||
@@ -77,8 +77,8 @@ export const usePlayerSettingsStore = create<
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface DownloadHistoryStoreState {
|
interface DownloadHistoryStoreState {
|
||||||
downloads: Download[];
|
downloads: DownloadContent[];
|
||||||
setDownloads: (downloads: Download[]) => void;
|
setDownloads: (downloads: ReactStyleStateSetter<DownloadContent[]>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDownloadHistoryStore = create<
|
export const useDownloadHistoryStore = create<
|
||||||
@@ -88,7 +88,18 @@ export const useDownloadHistoryStore = create<
|
|||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
downloads: [],
|
downloads: [],
|
||||||
setDownloads: (downloads: Download[]) => set({ downloads }),
|
setDownloads: (newDownloadsOrSetterFn) => {
|
||||||
|
set(({ downloads }) => {
|
||||||
|
if (Array.isArray(newDownloadsOrSetterFn)) {
|
||||||
|
const newArr = newDownloadsOrSetterFn;
|
||||||
|
return { downloads: newArr };
|
||||||
|
}
|
||||||
|
const setterFn = newDownloadsOrSetterFn;
|
||||||
|
return {
|
||||||
|
downloads: setterFn(downloads),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "download-history",
|
name: "download-history",
|
||||||
@@ -112,8 +123,8 @@ export const useBookmarkStore = create<
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
bookmarks: [],
|
bookmarks: [],
|
||||||
setBookmarks: (bookmarks: ItemData[]) => set({ bookmarks }),
|
setBookmarks: (bookmarks) => set({ bookmarks }),
|
||||||
addBookmark: (item: ItemData) =>
|
addBookmark: (item) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
bookmarks: [...state.bookmarks, item],
|
bookmarks: [...state.bookmarks, item],
|
||||||
})),
|
})),
|
||||||
@@ -123,7 +134,7 @@ export const useBookmarkStore = create<
|
|||||||
(bookmark) => bookmark.id !== item.id,
|
(bookmark) => bookmark.id !== item.id,
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
isBookmarked: (item: ItemData) =>
|
isBookmarked: (item) =>
|
||||||
Boolean(get().bookmarks.find((bookmark) => bookmark.id === item.id)),
|
Boolean(get().bookmarks.find((bookmark) => bookmark.id === item.id)),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -159,13 +170,13 @@ export const useWatchHistoryStore = create<
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
watchHistory: [],
|
watchHistory: [],
|
||||||
hasWatchHistoryItem: (item: ItemData) =>
|
hasWatchHistoryItem: (item) =>
|
||||||
Boolean(
|
Boolean(
|
||||||
get().watchHistory.find(
|
get().watchHistory.find(
|
||||||
(historyItem) => historyItem.item.id === item.id,
|
(historyItem) => historyItem.item.id === item.id,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
getWatchHistoryItem: (media: ScrapeMedia) =>
|
getWatchHistoryItem: (media) =>
|
||||||
get().watchHistory.find((historyItem) => {
|
get().watchHistory.find((historyItem) => {
|
||||||
if (historyItem.media.type === "movie" && media.type === "movie") {
|
if (historyItem.media.type === "movie" && media.type === "movie") {
|
||||||
return historyItem.media.tmdbId === media.tmdbId;
|
return historyItem.media.tmdbId === media.tmdbId;
|
||||||
@@ -180,8 +191,7 @@ export const useWatchHistoryStore = create<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
setWatchHistory: (watchHistory: WatchHistoryItem[]) =>
|
setWatchHistory: (watchHistory) => set({ watchHistory }),
|
||||||
set({ watchHistory }),
|
|
||||||
updateWatchHistory: (
|
updateWatchHistory: (
|
||||||
item: ItemData,
|
item: ItemData,
|
||||||
media: ScrapeMedia,
|
media: ScrapeMedia,
|
||||||
@@ -199,7 +209,7 @@ export const useWatchHistoryStore = create<
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
removeFromWatchHistory: (item: ItemData) =>
|
removeFromWatchHistory: (item) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
watchHistory: state.watchHistory.filter(
|
watchHistory: state.watchHistory.filter(
|
||||||
(historyItem) => historyItem.item.id !== item.id,
|
(historyItem) => historyItem.item.id !== item.id,
|
||||||
@@ -229,13 +239,11 @@ export const useNetworkSettingsStore = create<
|
|||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
allowMobileData: false,
|
allowMobileData: false,
|
||||||
setAllowMobileData: (enabled: boolean) =>
|
setAllowMobileData: (enabled) => set({ allowMobileData: enabled }),
|
||||||
set({ allowMobileData: enabled }),
|
|
||||||
wifiDefaultQuality: "Highest",
|
wifiDefaultQuality: "Highest",
|
||||||
setWifiDefaultQuality: (quality: string) =>
|
setWifiDefaultQuality: (quality) => set({ wifiDefaultQuality: quality }),
|
||||||
set({ wifiDefaultQuality: quality }),
|
|
||||||
mobileDataDefaultQuality: "Lowest",
|
mobileDataDefaultQuality: "Lowest",
|
||||||
setMobileDataDefaultQuality: (quality: string) =>
|
setMobileDataDefaultQuality: (quality) =>
|
||||||
set({ mobileDataDefaultQuality: quality }),
|
set({ mobileDataDefaultQuality: quality }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@movie-web/prettier-config",
|
"prettier": "@movie-web/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@movie-web/providers": "^2.2.8",
|
"@movie-web/providers": "^2.2.9",
|
||||||
"parse-hls": "^1.0.7",
|
"parse-hls": "^1.0.7",
|
||||||
"srt-webvtt": "^2.0.0",
|
"srt-webvtt": "^2.0.0",
|
||||||
"tmdb-ts": "^1.6.1"
|
"tmdb-ts": "^1.6.1"
|
||||||
|
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -268,8 +268,8 @@ importers:
|
|||||||
packages/provider-utils:
|
packages/provider-utils:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@movie-web/providers':
|
'@movie-web/providers':
|
||||||
specifier: ^2.2.8
|
specifier: ^2.2.9
|
||||||
version: 2.2.8
|
version: 2.2.9
|
||||||
parse-hls:
|
parse-hls:
|
||||||
specifier: ^1.0.7
|
specifier: ^1.0.7
|
||||||
version: 1.0.7
|
version: 1.0.7
|
||||||
@@ -2897,8 +2897,8 @@ packages:
|
|||||||
tslib: 2.6.2
|
tslib: 2.6.2
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@movie-web/providers@2.2.8:
|
/@movie-web/providers@2.2.9:
|
||||||
resolution: {integrity: sha512-fsksIYuRn39TLC1PLMZrM6AW5kRQCWFmK0aK/p9bTui0ojs6aXLIZbvIwK0svzKLP2pmH6xJEhALxF8SYPE72Q==}
|
resolution: {integrity: sha512-NHsyplM9Oe4DK3lIkNaEk0CqoQ6IqlaWXeDh01jj+DH4I4EJjSD4ow7OTeAC+BLz3Gwj6fh/vaE2WBGevPTDkQ==}
|
||||||
requiresBuild: true
|
requiresBuild: true
|
||||||
dependencies:
|
dependencies:
|
||||||
cheerio: 1.0.0-rc.12
|
cheerio: 1.0.0-rc.12
|
||||||
|
Reference in New Issue
Block a user