rework downloads

This commit is contained in:
Jorrin
2024-04-06 22:27:55 +02:00
parent b2f1782311
commit 8f5d0247bb
15 changed files with 588 additions and 527 deletions

View File

@@ -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,13 +123,17 @@ const DownloadsScreen: React.FC = () => {
gap: "$4", gap: "$4",
}} }}
> >
{downloads.map((item) => ( {/* TODO: Differentiate movies/shows, shows in new page */}
<DownloadItem {downloads
key={item.id} .map((item) => item.downloads)
item={item} .flat()
onPress={() => handlePress(item.localPath)} .map((item) => (
/> <DownloadItem
))} key={item.id}
item={item}
onPress={() => handlePress(item.localPath)}
/>
))}
</ScrollView> </ScrollView>
</ScreenLayout> </ScreenLayout>
); );

View File

@@ -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,
}); });
} }
}; };

View File

@@ -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>

View File

@@ -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);
} }
}; };

View File

@@ -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,
}); });
} }
}; };

View File

@@ -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

View File

@@ -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";

View File

@@ -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";

View File

@@ -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>
);
};

View 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,
};
};

View 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 };
};

View File

@@ -0,0 +1 @@
export type ReactStyleStateSetter<T> = T | ((prev: T) => T);

View File

@@ -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 }),
}), }),
{ {

View File

@@ -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
View File

@@ -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